diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8974daf..d819e13 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -3,16 +3,20 @@ # player falls back to its silent clock when the VM has no sound device. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push +# or dependency change: a drifted dependency still shows up within a day, and always before a +# release ships. PRs that change the FreeBSD packaging itself still run it. on: + schedule: + - cron: "47 3 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - "crates/playback/**" - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh workflow_dispatch: concurrency: @@ -24,27 +28,5 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main \ No newline at end of file diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ce126e3..f54b053 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,49 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"] - assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio" - for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1c0a39..d60e820 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "SoundCraft v". See docs/releasing.md. -# -# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release` -# environment (restricted to the `release` branch), which holds the signing secrets. Every secret -# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them -# (a dry run); the signed jobs are refused there, and so the draft release is skipped. name: Release on: @@ -19,359 +11,32 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - SOUNDCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets (and gates every other job). - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@v4 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - libasound2-dev pkg-config \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications' - ldd target/release/soundcraft - # cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml). - ldd target/release/soundcraft | grep -q libasound - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml). - runs-on: ubuntu-latest - timeout-minutes: 150 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - if: inputs.version != '' - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Build and package in a FreeBSD VM - # v1.5.9, pinned by commit. - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - mem: 12288 - # Passed through by name; never templated into the script. - envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA" - # alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime). - prepare: | - pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM. - export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4 - bash packaging/freebsd/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Build and zip - run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts. - needs: [version, macos, windows, linux, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@v4 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="SoundCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: soundcraft + app-title: SoundCraft + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index bafdca6..d389f4d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -4,19 +4,17 @@ # out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "53 5 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - .github/workflows/windows-arm64.yml - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up workflow_dispatch: concurrency: @@ -26,70 +24,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 - with: - name: soundcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@v4 - with: - name: soundcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'SoundCraft' - foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'soundcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok soundcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: soundcraft + app-dir: SoundCraft diff --git a/AGENTS.md b/AGENTS.md index 629b9fa..4fdf3cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed). -2. Read `ROADMAP.md` (committed) for status, milestones and the parity estimate. +2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. 3. Work autonomously; don't stop to ask unless a decision is genuinely the user's. ## ⚠️ Assets: the absolute rule @@ -51,7 +51,7 @@ Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read e ## Everything is a command User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`. -Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises. +Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises. ## Running and looking at the app - `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed). @@ -66,10 +66,10 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc `cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit. ## Release -Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. +Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands. +`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry. ## Contributor credits (About window) diff --git a/Cargo.lock b/Cargo.lock index 0642d45..480e113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,15 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -837,23 +846,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "dark-light" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745" -dependencies = [ - "futures-channel", - "futures-core", - "objc2 0.6.5", - "objc2-foundation 0.3.2", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", - "winreg", - "zbus", -] - [[package]] name = "dasp_sample" version = "0.11.0" @@ -1319,15 +1311,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -3429,12 +3412,14 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.3.0" +version = "0.5.0" dependencies = [ "eframe", "egui", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "rfd", "serde_json", "soundcraft-engine", @@ -3445,7 +3430,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.3.0" +version = "0.5.0" dependencies = [ "log", "serde", @@ -3455,7 +3440,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.3.0" +version = "0.5.0" dependencies = [ "log", "proptest", @@ -3467,7 +3452,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.3.0" +version = "0.5.0" dependencies = [ "log", "serde_json", @@ -3477,7 +3462,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.3.0" +version = "0.5.0" dependencies = [ "clap-sys", "libloading", @@ -3489,7 +3474,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.3.0" +version = "0.5.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3500,7 +3485,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.3.0" +version = "0.5.0" dependencies = [ "log", "proptest", @@ -3511,7 +3496,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.3.0" +version = "0.5.0" dependencies = [ "log", "proptest", @@ -3532,7 +3517,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.3.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3542,7 +3527,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.3.0" +version = "0.5.0" dependencies = [ "log", "rayon", @@ -3558,7 +3543,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.3.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3571,7 +3556,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.3.0" +version = "0.5.0" dependencies = [ "cpal", "log", @@ -3584,7 +3569,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.3.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3594,9 +3579,9 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.3.0" +version = "0.5.0" dependencies = [ - "dark-light", + "craft-ui", "egui", "egui_kittest", "image", @@ -3620,7 +3605,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.3.0" +version = "0.5.0" dependencies = [ "image", "proptest", @@ -3631,7 +3616,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.3.0" +version = "0.5.0" dependencies = [ "libloading", "log", @@ -3646,7 +3631,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.3.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -5106,16 +5091,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "winresource" version = "0.1.31" @@ -5203,7 +5178,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.3.0" +version = "0.5.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 5e69697..4eaad85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.3.0" +version = "0.5.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -41,10 +41,10 @@ rustfft = "6" cpal = "0.16" # ui +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" -dark-light = "3" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/README.md b/README.md index 89ec40e..b297d75 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ where things are. ## Getting started Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build -from source with a recent stable Rust: +from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared +minimum remains Rust 1.90): ```sh git clone https://github.com/storytold/soundcraft @@ -236,10 +237,15 @@ and layering checks and the WebAssembly build. ## Status and roadmap -SoundCraft is **pre-alpha, and close to its first alpha**: it covers 94 % of the incumbent's menu -items and roughly two thirds of its features in depth. The core works and is fun to use, and plenty is still missing. The -honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMAP.md); the -menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are +SoundCraft is **pre-alpha**: it covers 94 % of the incumbent's menu items (measured), about 70 % of +its features by presence, and is roughly 40 % of the way to replacing it for real work: record, +edit, mix and bounce work end to end, but choosing an audio interface and buffer size, +recording MIDI from a controller and session interchange (AAF/OMF) are still missing, and those +are the alpha checklist. Translations are missing too. The status, the stage and our effort +estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in +[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in +[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in +[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are very welcome, in the issues or on Discord. ## Downloads @@ -271,6 +277,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or +`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"` +(or `"auto"`) selects wgpu's DXC handling again for troubleshooting. + ### macOS | Build | File | Notes | @@ -288,6 +298,8 @@ and web backend defaults are unchanged. | Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | | | Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere | +RISC-V (riscv64): `soundcraft--linux-riscv64.tar.gz`, a tarball only (glibc 2.39+, e.g. Ubuntu 24.04). + ### FreeBSD | Build | File | @@ -366,3 +378,5 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. diff --git a/ROADMAP.md b/ROADMAP.md index a89da3f..25ae055 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,54 +1,114 @@ # SoundCraft roadmap +**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with -Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This file is -the honest status: what works today, what is missing, and how far we are. +Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is +the one-page summary; the detail is in [`docs/target-app-parity.md`](docs/target-app-parity.md) +(assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md) +(milestones and beta plan) and [`docs/parity-checklist.md`](docs/parity-checklist.md) (generated +menu checklist). -## Where we are (2026-10-07) +## Headline numbers -| Measure | Value | How | +| Number | Value | Kind | |---|---|---| -| Menu-catalog parity (engine + UI) | **482 / 512 menu items (94 %)**; engine alone 397 / 512, see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry. Most of what is left is Avid's online services (sign-in, cloud projects, collaboration, Splice, Learn), Dolby Atmos-specific items, HEAT and ARA | -| Estimated overall feature parity | **~65 %** | judgement across the areas below, weighted by how much working engineers rely on them. Menu items are only the surface; depth (editing feel, plugin catalogue, MIDI and score editing, video) is where the gap is | -| Distance to an **alpha** | **~85 % there, ~12–18 wall-clock hours** of Claude Opus 5.5 work | see [Alpha](#alpha) below | -| Distance to **100 % parity** | **~110–150 wall-clock hours** of Claude Opus 5.5 work with a single agent; roughly 40–55 hours with 3–4 agents in parallel (the last three features in this session were built in parallel this way) | sum of the per-area estimates below | +| Menu breadth | **482 / 512 Pro Tools menu leaves (94.1 %)**; engine alone 397 / 512 | measured (`cargo xtask parity` + UI-layer aliases) | +| Feature breadth | **~70 %** | estimated (menus plus plugins, hardware, formats, immersive, cloud) | +| **Ready for real work** | **~40 %** | estimated, weighted by dimension | +| Mainstream practitioner (tracking/mixing engineer) | **~36 %** | estimated: 50.6 % weekly-area depth × 0.90 interaction × 0.92 stability × 0.85 file exchange ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) | +| Essentials user (song or podcast, defaults) | **~57 %** | estimated: 72.4 % core-feature depth × 0.90 launch/stability × 0.90 discoverability × 0.97 files ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) | +| Remaining to alpha | **~110–180 Opus 5.5 agent-hours** (the failing gate rows) | estimated, calibrated on PR history | +| Remaining to beta | **~350–570 Opus 5.5 agent-hours** (includes the alpha work; ~60–70 % parallelizable) | estimated | +| Remaining to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated | -### Area by area +| Audience | Ready % | Agent-hours to ~95 % | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ~40 % | ~950–1,450 h | Hardware, the stock plugin catalogue, AAF/OMF and immersive, localization, fidelity | +| Mainstream practitioner | ~36 % | ~380–600 h | Interface and MIDI recording, AAF/OMF, stock-plugin depth, core-path bugs | +| Essentials user | ~57 % | ~110–200 h | Launch and platform fixes, onboarding, effect panels and presets, MP3/AAC export | -| Area | Status | Parity | Remaining (h) | -|---|---|---:|---:| -| Session model, save/load, undo | Tracks, playlists, clips, fades, clip gain, automation, markers, groups, busses, video; JSON `.scraft` + Audio Files folder; plugin state; autosave; copy-on-write undo with gesture coalescing | 85 % | 8 | -| Audio file I/O | WAV/BWF/RF64, AIFF/AIFC, FLAC (read/write); MP3, OGG, AAC, ALAC, CAF (read); peaks. Whole files are decoded into memory: disk streaming for very long sessions is still to do | 70 % | 12 | -| Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, Paste Special (merge MIDI, markers, clip gain/effects), separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists with comping, every tool gesture, edit groups, clip groups (and `.scgrp` import/export) | 75 % | 20 | -| Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects, preamp/instrument/object views | 75 % | 15 | -| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce. Surround-pan automation is still to do | 60 % | 15 | -| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 | -| Third-party plugins | CLAP, VST3 and Audio Units (macOS): audio, parameters, latency, notes, state saved in sessions, instances created/destroyed off the audio thread, editors (floating CLAP GUIs, VST3 views on macOS, verified with Melodyne). Missing: AU editors, embedded CLAP GUIs, VST3 editors on Windows/Linux, ARA | 80 % | 10 | -| MIDI & notation | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll, velocity, rubber-band select, keyboard transpose/move), event list, step input, quantize/transpose/velocity/duration, real-time properties, Score Editor, MusicXML export (Sibelius) and printable SVG score | 55 % | 20 | -| Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 10 | -| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 | -| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 | -| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 | -| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 | -| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 | -| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 | +Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's PRs; 60–70 % parallelizes. +Detail: [target-app-parity.md](docs/target-app-parity.md#readiness-by-audience). -## Alpha +**Why pre-alpha:** the core-workflow gate in [docs/roadmap.md](docs/roadmap.md#alpha-gate) fails. +Editing, mixing, bouncing, and saving and reopening work end to end. But a Pro Tools user can't +record from a chosen audio interface at a chosen buffer size (only the default devices, a fixed +512-sample block, and takes held in RAM). They can't record MIDI from a keyboard (there is no +MIDI device layer). And they can't bring a session in or hand one back (no AAF/OMF/`.ptx`). +Readiness (~40 %) sits right at the alpha bar, but a gate failure keeps the app pre-alpha. The +2026-10-07 estimate ("~65 % overall, 110–150 h to 100 %") counted feature presence. This one +measures readiness and adds hardware, localization and field bug reports. -An alpha is a build people can download, open the demo or their own audio in, record, edit, mix -and bounce without crashes or lost work. Feature-wise SoundCraft is past that bar; what is left is -shipping and hardening: +## By dimension -1. **Publish the repository and cut the first release** (needs a decision from the maintainers: - the GitHub repo, the release secrets, and a push to the `release` branch). The pipelines exist - but have never run end to end. ~2 h once unblocked, mostly fixing what the first run finds. -2. **Platform smoke tests** of the installers on Windows, Linux and FreeBSD (only macOS has been - exercised by hand). ~3–4 h. -3. **Disk streaming** of long sources, so an hour-long multitrack session does not need all of its - audio in RAM. ~5–6 h. -4. **A hands-on QA pass** in the live app: recording on real interfaces, long sessions, plugin-heavy - mixes, save/reopen loops. ~3–4 h. -5. **Docs**: a short user guide next to the existing agent/CLI docs. ~1 h. +| Dimension | Ready | Remaining (h) | Doc | +|---|---:|---:|---| +| Features | ~45 % | 520–810 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| UI/UX fidelity | ~55 % | 60–100 | [ui-parity.md](docs/ui-parity.md) | +| File formats | ~40 % | 70–120 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware (interfaces, MIDI, surfaces, sync) | ~12 % | 110–180 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization | ~12 % (1 of Pro Tools' 8 languages) | 70–110 | [localization-parity.md](docs/localization-parity.md) | +| Performance | ~35 % (unmeasured) | 40–70 | [hardware-parity.md](docs/hardware-parity.md#performance) | +| Stability | ~45 % | 50–80 | [gaps.md](docs/gaps.md) | +| Platforms | ~65 % (5 platforms built, macOS exercised) | 20–40 | [gaps.md](docs/gaps.md) | +| Ecosystem / plugins | ~30 % | in Features + Hardware | [plugin-parity.md](docs/plugin-parity.md) | +| AI features | ~5 % | 30–50 | [gaps.md](docs/gaps.md) | +| Agent control | ahead of Pro Tools | 3 | [control-protocol.md](docs/control-protocol.md), [mcp.md](docs/mcp.md) | + +## Features + +| Area | Breadth | Ready | Remaining (h) | +|---|---:|---:|---:| +| Recording and monitoring | 75 % | 40 % | 40–60 | +| Editing | 92 % | 65 % | 40–60 | +| Mixing and routing | 85 % | 60 % | 35–50 | +| Automation | 80 % | 55 % | 20–30 | +| Built-in plugins and AudioSuite | 40 % | 35 % | 80–120 | +| Third-party plugin hosting | 70 % | 45 % | 40–60 | +| MIDI and instruments | 70 % | 30 % | 50–80 | +| Score and notation | 60 % | 35 % | 20–30 | +| Elastic Audio, TCE, Beat Detective | 70 % | 35 % | 40–60 | +| Surround and immersive | 55 % | 35 % | 50–80 | +| Video and post | 60 % | 40 % | 40–60 | +| Session, file management, interchange | 55 % | 35 % | 60–100 | +| Collaboration and cloud (Avid services) | 0 % | 0 % | owner decision | +| Agent control | 100 % | 100 % | 3 | + +Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#feature-areas). + +## Languages + +| Language | Code | Status | Translated | +|---|---|---|---:| +| English | en | full | 100 % | +| Simplified Chinese | zh-Hans | none | 0 % | +| Spanish | es | none (PR #143 open) | 0 % | +| Hindi | hi | none | 0 % | +| Arabic | ar | none | 0 % | +| French | fr | none (PR #143 open) | 0 % | +| Portuguese | pt | none | 0 % | +| Indonesian | id | none | 0 % | +| Japanese | ja | none | 0 % | +| German | de | none | 0 % | +| Korean | ko | none | 0 % | +| Vietnamese | vi | none | 0 % | + +Other languages shipped: 0. Detail: [localization-parity.md](docs/localization-parity.md). + +## Upcoming + +Ranked; the alpha gate, the alpha checklist and the beta plan are in [docs/roadmap.md](docs/roadmap.md). + +1. **Alpha blocker:** audio interfaces: device and buffer-size choice, duplex stream, ASIO; record to disk (G-01, G-06). 35–55 h. +2. **Alpha blocker:** MIDI devices: record from keyboards, play instruments live, output (G-02). 20–35 h. +3. **Alpha blocker:** AAF/OMF import and export (G-03). 50–90 h. +4. macOS plugin entitlement (#153), then AU editors (G-05). 1 h for the entitlement (needed for the mix row), then 15–25 h. +5. Land the open bug-fix PRs and clear the correctness backlog (G-04). 15–25 h. +6. PR CI running `cargo xtask ci` on three OSes (G-14). 5–10 h. +7. Fidelity harness against Pro Tools (G-07). 20–30 h. ## Robustness @@ -57,22 +117,35 @@ command parameters through the engine and mixer; they have caught fade, overflow unbounded-allocation bugs. Every command also runs with empty parameters on empty and demo sessions. Hosted plugins live in isolated unsafe crates (`clap-host`, `vst3-host`, `au-host`) and are created, loaded and destroyed off the audio thread. The video decoders survive hundreds of -mutated and truncated movies. +mutated and truncated movies. Field reports from the first public week are tracked in +[gaps.md](docs/gaps.md) (G-04). -## Current focus +## Progress log -1. The alpha list above. -2. Surround-pan automation; AU editor views. -3. Built-in plugin depth (more processors, better UIs). -4. MIDI and score editing depth. +- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. +- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard + progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ + localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours, + tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session. +- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media + warnings, session audio health, malformed-JSON and encoder-extension checks in the CLI, macOS + system menu bar, Windows DX12 default, Linux fontconfig, metronome click in playback. +- **2026-10-08** — v0.2.x–v0.3.0 released (Flatpak, AppImage zsync); VST3 quit crash fixed; + Ctrl-as-Cmd shortcuts on Windows/Linux; plugin menus by vendor, AU inserts, instrument picker; + rotating log file; branded DMG. +- **2026-10-07** — Repository published; About window credits; release CI on macOS runners. +- **2026-10-06** — Video track, Audio Unit hosting, plugin state and editors, Renderer; Mix + window views; score export and print; surround mixing; VST3 hosting; property tests. +- **2026-10-05** — Foundation crates, Edit and Mix windows, playback, CLI/MCP, parity report, + recording and punch, CLAP hosting, Elastic Audio, Beat Detective, release pipeline. -## Milestones +## Revision history -- **M0–M4 (done):** workspace, gates, model, IO, DSP, mix engine, command engine, Edit/Mix UI, realtime playback, metering, plugins, bounce. -- **M5 (done):** editing depth (tools, modes, playlists, fades, comping, clip groups). -- **M6 (done for the first cut):** recording, punch, loop record, monitoring. -- **M7 (in progress):** MIDI and notation editing. -- **M8 (done):** CLI, control channel, MCP, agent acceptance test. -- **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective. -- **M10 (next):** alpha release on all platforms. -- **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround. +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h | +| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers | +| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added | +| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | +| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list | +| 2026-10-06 | minor | Alpha and parity estimates | diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..5840b6d 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -20,11 +20,12 @@ use soundcraft_engine::Engine; use std::io::Write; use std::process::ExitCode; -/// Print without panicking on a closed pipe. +/// Print without panicking on a closed pipe. A write error must not skip later work +/// such as `--bounce` or `--save`, and must not by itself report success. macro_rules! outln { ($($t:tt)*) => {{ let mut o = std::io::stdout().lock(); - if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; } + let _ = writeln!(o, $($t)*); }}; } @@ -294,12 +295,45 @@ fn describe(args: &[String]) -> ExitCode { } } +/// Control-channel methods. Every other dotted id is an engine command. +fn is_control_method(method: &str) -> bool { + matches!( + method, + "engine.execute" + | "command" + | "engine.commands" + | "engine.parity" + | "session.inspect" + | "document.inspect" + | "ui.inspect" + | "ui.menu.list" + | "ui.menu.invoke" + | "ui.set" + | "ui.move" + | "ui.click" + | "ui.drag" + | "ui.key" + | "ui.text" + | "ui.screenshot" + | "app.quit" + ) +} + +/// A command id is shorthand for `engine.execute`. Documented control methods stay methods. +fn app_request(method: &str, params: Value) -> (String, Value) { + if is_control_method(method) || !method.contains('.') { + (method.to_string(), params) + } else { + ("engine.execute".to_string(), json!({"command": method, "params": params})) + } +} + fn app(args: &[String]) -> ExitCode { let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into()); let positional: Vec<&String> = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))) + .filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))) .map(|(_, a)| a) .collect(); let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") }; @@ -311,17 +345,8 @@ fn app(args: &[String]) -> ExitCode { None => json!({}), }; let mut r = Remote::new(&port); - // A bare command id is shorthand for engine.execute. - let (m, p) = if method.contains('.') - && !method.starts_with("ui.") - && !method.starts_with("engine.") - && !method.starts_with("session.") - && !method.starts_with("app.") - { - ("engine.execute".to_string(), json!({"command": method, "params": params})) - } else { - (method.to_string(), params) - }; + // A bare command id is shorthand for engine.execute. Control-channel methods stay methods. + let (m, p) = app_request(method, params); match r.call(&m, p) { Ok(v) => { outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -351,7 +376,7 @@ fn mcp(args: &[String]) -> ExitCode { fn parity(args: &[String]) -> ExitCode { let p = soundcraft_engine::catalog::parity_json(); let mut md = String::new(); - md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).\n"); + md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\n"); md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n"); md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n"); md.push_str(&format!( @@ -380,6 +405,23 @@ fn parity(args: &[String]) -> ExitCode { mod tests { use super::*; + #[test] + fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() { + let (m, p) = app_request("engine.history", json!({})); + assert_eq!(m, "engine.execute"); + assert_eq!(p["command"], "engine.history"); + let (m, p) = app_request("ui.theme", json!({"mode": "light"})); + assert_eq!(m, "engine.execute"); + assert_eq!(p["params"]["mode"], "light"); + let (m, _) = app_request("session.new", json!({"name": "Session A"})); + assert_eq!(m, "engine.execute"); + let (m, _) = app_request("track.new", json!({})); + assert_eq!(m, "engine.execute"); + for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] { + assert_eq!(app_request(method, json!({})).0, method); + } + } + #[test] fn missing_media_gets_one_warning_each() { assert!(missing_warnings(&[]).is_empty()); diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/closed_stdout.rs new file mode 100644 index 0000000..9807423 --- /dev/null +++ b/apps/soundcraft-cli/tests/closed_stdout.rs @@ -0,0 +1,124 @@ +//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`. + +use std::process::{Command, Stdio}; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +/// Drop the read end so the child's stdout writes fail with `EPIPE`. +fn closed_stdout() -> std::process::Stdio { + let (reader, writer) = std::io::pipe().expect("pipe"); + drop(reader); + Stdio::from(writer) +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn run_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("run"); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("run") + .arg("--cmd") + .arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file(), "bounce was skipped"); + assert!(session.is_file(), "save was skipped"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("script"); + let script = dir.join("edit.scraft.txt"); + std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap(); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("script") + .arg(&script) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bounce_then_save_with_closed_stdout_writes_the_wav() { + let dir = temp_dir("bounce"); + let wav = dir.join("mix.wav"); + let session = dir.join("Empty.scraft"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn invalid_bounce_destination_still_fails() { + let dir = temp_dir("bad-bounce"); + let missing = dir.join("no-such-dir").join("mix.wav"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&missing) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(!output.status.success(), "a bounce that cannot be written must fail"); + assert!(!missing.exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index cb9dab1..783c7b5 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -28,3 +28,5 @@ winresource = "0.1" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.19.2", default-features = false } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] } diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs new file mode 100644 index 0000000..1ade4ee --- /dev/null +++ b/apps/soundcraft/src/config.rs @@ -0,0 +1,244 @@ +//! Per-user settings and automation profile directories. + +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Clone, Copy)] +enum Platform { + Mac, + Windows, + Other, +} + +/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default. +/// The override is the complete directory; no application-name suffix is appended. An explicitly +/// empty value disables the settings directory instead of falling back to a personal profile. +pub fn config_dir() -> Option { + let platform = if cfg!(target_os = "macos") { + Platform::Mac + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Other + }; + config_dir_with(platform, |name| std::env::var_os(name)) +} + +fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option { + if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") { + return (!dir.is_empty()).then(|| PathBuf::from(dir)); + } + match platform { + Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")), + Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")), + Platform::Other => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft")) + } + } +} + +/// Read bounded settings, recovering unreadable docking metadata independently of other settings. +pub fn load_ui(path: &std::path::Path) -> Result, String> { + read_ui(path, true) +} + +fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result, String> { + use std::io::Read as _; + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Settings could not be read: {error}")), + }; + let mut bytes = Vec::new(); + file.take(MAX_UI_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("Settings could not be read: {error}"))?; + if bytes.len() as u64 > MAX_UI_BYTES { + return Err("Settings file exceeds the 4 MiB limit".into()); + } + let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?; + if recover_docking && let Some(fields) = saved.as_object_mut() { + for (field, mix) in [("docking", false), ("mix_docking", true)] { + let unreadable = + fields.get(field).is_some_and(|value| match serde_json::from_value::(value.clone()) { + Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(), + Err(_) => true, + }); + if unreadable { + fields.remove(field); + } + } + } + let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?; + state.docking.validate()?; + state.mix_docking.validate_mix()?; + Ok(Some(state)) +} + +const MAX_UI_BYTES: u64 = 4 * 1024 * 1024; + +/// Write settings atomically. Preserve an unreadable or malformed existing file for recovery. +pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> { + use std::io::Write as _; + // Loading can recover a future layout for this session, but saving must preserve its source. + read_ui(path, false)?; + ui.docking.validate()?; + ui.mix_docking.validate_mix()?; + let mut saved = ui.clone(); + saved.plugin_windows.clear(); + saved.status.clear(); + let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_UI_BYTES { + return Err("Settings exceed the 4 MiB limit".into()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|error| error.to_string())?.as_nanos(); + let temporary = path.with_extension(format!("json.{}.{nonce}.tmp", std::process::id())); + let mut file = + std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|error| format!("Settings could not be saved: {error}"))?; + let written = (|| -> std::io::Result<()> { + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&temporary, path) + })(); + if written.is_err() { + let _ = std::fs::remove_file(&temporary); + } + written.map_err(|error| format!("Settings could not be saved: {error}")) +} + +#[cfg(test)] +mod config_tests { + use super::*; + + #[test] + fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() { + use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel}; + let directory = std::env::temp_dir().join(format!("soundcraft-dock-prefs-{}", std::process::id())); + let path = directory.join("ui.json"); + let _ = std::fs::remove_dir_all(&directory); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"close","panel":"tracks"})).unwrap(); + app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light; + let edit = serde_json::to_value(&app.ui.docking).unwrap(); + app.ui.window = MainWindow::Mix; + app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[140,150,280,420]})).unwrap(); + save_ui(&path, &app.ui).unwrap(); + app.ui = load_ui(&path).unwrap().unwrap(); + assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light); + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit); + assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]); + app.ui.window = MainWindow::Edit; + app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap(); + assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]); + let mut invalid_mix = app.ui.clone(); + invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default(); + let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap(); + std::fs::write(&path, &invalid_bytes).unwrap(); + let recovered = load_ui(&path).unwrap().unwrap(); + assert_eq!(recovered.theme, app.ui.theme); + assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap()); + recovered.mix_docking.validate_mix().unwrap(); + assert!(save_ui(&path, &app.ui).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes); + std::fs::write(&path, b"damaged settings").unwrap(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &app.ui).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"damaged settings"); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn future_edit_and_mix_layouts_preserve_other_preferences_and_the_original_file() { + use soundcraft_ui_egui::{Services, SoundApp}; + let directory = std::env::temp_dir().join(format!("soundcraft-future-dock-prefs-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ui.json"); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap(); + app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light; + app.ui.narrow_mix = true; + for field in ["docking", "mix_docking"] { + for invalid in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { + let mut saved = serde_json::to_value(&app.ui).unwrap(); + saved[field] = invalid; + assert!(serde_json::from_value::(saved.clone()).is_err()); + let bytes = serde_json::to_vec(&saved).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let recovered = load_ui(&path).unwrap().unwrap(); + assert_eq!(recovered.theme, app.ui.theme); + assert!(recovered.narrow_mix); + recovered.docking.validate().unwrap(); + recovered.mix_docking.validate_mix().unwrap(); + let other = if field == "docking" { "mix_docking" } else { "docking" }; + assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]); + assert!(save_ui(&path, &recovered).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + } + } + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn oversized_settings_are_rejected_and_preserved() { + let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ui.json"); + let bytes = vec![b' '; MAX_UI_BYTES as usize + 1]; + std::fs::write(&path, &bytes).unwrap(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &soundcraft_ui_egui::UiState::default()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + std::fs::remove_dir_all(directory).unwrap(); + } + + fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option { + config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value)))) + } + + #[test] + fn config_override_wins_without_reading_user_profile() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + let actual = config_dir_with(platform, |name| { + assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR"); + Some(OsString::from("isolated/settings")) + }); + assert_eq!(actual, Some(PathBuf::from("isolated/settings"))); + } + } + + #[test] + fn config_empty_override_disables_profile_without_reading_defaults() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!( + config_dir_with(platform, |name| { + assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR"); + Some(OsString::new()) + }), + None + ); + } + } + + #[test] + fn config_unset_override_keeps_platform_defaults() { + let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")]; + assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/SoundCraft"))); + assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/SoundCraft"))); + assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/soundcraft"))); + assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/soundcraft"))); + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!(resolve(platform, &[]), None); + } + } + + #[cfg(unix)] + #[test] + fn config_override_preserves_non_unicode_paths() { + use std::os::unix::ffi::OsStringExt as _; + let value = OsString::from_vec(b"isolated/\xff".to_vec()); + assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); + } +} diff --git a/apps/soundcraft/src/graphics.rs b/apps/soundcraft/src/graphics.rs index ccd0c45..2a932a6 100644 --- a/apps/soundcraft/src/graphics.rs +++ b/apps/soundcraft/src/graphics.rs @@ -1,10 +1,14 @@ //! Windows graphics instance configuration, applied before wgpu can initialize any driver. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{Backends, Dx12Compiler}, +}; -/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally -/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { +/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit +/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence. +pub fn configure(options: &mut NativeOptions, backend_override: Option, compiler_override: Option) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { // eframe's default includes GL. Creating that backend can crash inside AMD's // atio6axx.dll before adapter selection or any Rust error handling runs, so the @@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 // workaround without mutating process environment variables. create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); + // Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships + // none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's + // indirect-validation pipeline and loses the device before the first frame + // (issue #185). FXC uses the compiler that is part of Windows. + create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc); + log::info!( + "Windows graphics: backends={:?}, DX12 compiler={:?}", + create.instance_descriptor.backends, + create.instance_descriptor.backend_options.dx12.shader_compiler + ); } } @@ -21,7 +35,7 @@ mod tests { fn configured_backends(backend_override: Option) -> Backends { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + configure(&mut options, backend_override, None); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; @@ -38,6 +52,38 @@ mod tests { assert_eq!(configured_backends(None), Backends::DX12); } + #[test] + fn windows_default_uses_fxc_instead_of_searching_for_dxc() { + let mut options = NativeOptions::default(); + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // Auto can find an unrelated DXC without its matching DXIL runtime (issue #185). + create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto; + } + configure(&mut options, None, None); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + + #[test] + fn explicit_compiler_override_is_preserved() { + for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] { + let mut options = NativeOptions::default(); + configure(&mut options, None, Some(compiler.clone())); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) { + (Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {} + (Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => { + assert_eq!(actual, expected); + } + (expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"), + } + } + } + #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..8004acf 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -11,6 +11,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod config; mod control_server; #[cfg(any(target_os = "windows", test))] mod graphics; @@ -97,16 +98,7 @@ fn open_path(app: &mut SoundApp, p: &str) { /// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here. fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join("soundcraft")) - } + config::config_dir() } fn prefs_path() -> Option { @@ -122,24 +114,24 @@ fn load_prefs() -> Option { if !prefs_enabled() { return None; } - let bytes = std::fs::read(prefs_path()?).ok()?; - serde_json::from_slice(&bytes).ok() + let path = prefs_path()?; + match config::load_ui(&path) { + Ok(state) => state, + Err(error) => { + log::warn!("{error}"); + None + } + } } fn save_prefs(ui: &UiState) { if !prefs_enabled() { return; } - if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() { - let _ = std::fs::create_dir_all(dir); - } - let mut ui = ui.clone(); - ui.plugin_windows.clear(); - ui.status.clear(); - if let Ok(bytes) = serde_json::to_vec_pretty(&ui) { - let _ = std::fs::write(&p, bytes); - } + if let Some(path) = prefs_path() + && let Err(error) = config::save_ui(&path, ui) + { + log::warn!("{error}"); } } @@ -198,7 +190,7 @@ fn main() -> eframe::Result { let files: Vec = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))) + .filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))) .map(|(_, a)| a.clone()) .collect(); log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); @@ -215,7 +207,7 @@ fn main() -> eframe::Result { }, None => { logger.no_file(); - log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)"); + log::warn!("no log file: no settings directory (profile override is empty or platform configuration is unavailable)"); } } } @@ -233,9 +225,9 @@ fn main() -> eframe::Result { if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); } - // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); eframe::run_native( "SoundCraft", options, diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index 9cc72de..4bd085a 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -1,5 +1,7 @@ //! macOS system menu bar. All actions use the same menu dispatch as the egui host. -use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; use soundcraft_ui_egui::{SoundApp, menus}; use std::sync::mpsc::{Receiver, channel}; @@ -36,8 +38,9 @@ impl NativeMenu { app_menu.append(&PredefinedMenuItem::separator())?; app_menu.append(&PredefinedMenuItem::services(None))?; app_menu.append(&PredefinedMenuItem::separator())?; - app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?; - app_menu.append(&PredefinedMenuItem::hide_others(None))?; + // muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send. + app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?; + app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?; app_menu.append(&PredefinedMenuItem::show_all(None))?; app_menu.append(&PredefinedMenuItem::separator())?; native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?; @@ -82,7 +85,12 @@ impl NativeMenu { while let Ok(event) = self.events.try_recv() { // A click changes state (and Cocoa flips the item's check mark): sync on the next pass. self.last_sync = None; - if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) + if (event.id == "hide" || event.id == "hide_others") + && let Some(mtm) = MainThreadMarker::new() + { + let ns = NSApplication::sharedApplication(mtm); + if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) } + } else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) && let Some(command) = &entry.command && menus::item_state(app, &entry.path, Some(command)).0 { diff --git a/contributors/contributors.json b/contributors/contributors.json index 3370da8..5b4969d 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,192 @@ "schema": 1, "repo": "storytold/soundcraft", "branch": "main", - "head": "3597f895cc49ad795650839c1eeeeac0f88e2aa9", - "head_date": "2026-10-07T18:45:52Z", - "commits": 49, + "head": "330a6983973dc2043bb1fa0f0ba67fa07ae50685", + "head_date": "2026-10-10T18:08:49Z", + "commits": 111, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 1, - "commits": 49, - "lines_added": 78099, - "lines_deleted": 1928, + "prs": 6, + "commits": 58, + "lines_added": 80505, + "lines_deleted": 2222, "binary_added": 23, "binary_deleted": 0, "first_commit": "2026-10-06T00:08:04Z", - "last_commit": "2026-10-07T18:45:52Z" + "last_commit": "2026-10-10T18:08:49Z" + }, + { + "login": "reoniazx", + "people_entry": false, + "github_name": null, + "prs": 5, + "commits": 9, + "lines_added": 649, + "lines_deleted": 64, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T06:10:03Z", + "last_commit": "2026-10-10T05:15:18Z" + }, + { + "login": "switch87", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 981, + "lines_deleted": 18, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T10:25:49Z", + "last_commit": "2026-10-08T10:27:07Z" + }, + { + "login": "h-a-n-a-s-h-i", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 3, + "lines_added": 65, + "lines_deleted": 62, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:13:25Z", + "last_commit": "2026-10-08T14:02:25Z" + }, + { + "login": "XusBadia", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 243, + "lines_deleted": 1, + "binary_added": 2, + "binary_deleted": 0, + "first_commit": "2026-10-08T13:12:32Z", + "last_commit": "2026-10-08T13:12:32Z" + }, + { + "login": "ebbicott", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 5, + "lines_added": 791, + "lines_deleted": 97, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T17:45:32Z", + "last_commit": "2026-10-08T19:40:37Z" + }, + { + "login": "MarekSpodBiedry", + "people_entry": false, + "github_name": null, + "prs": 6, + "commits": 7, + "lines_added": 274, + "lines_deleted": 35, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T18:06:42Z", + "last_commit": "2026-10-09T19:25:52Z" + }, + { + "login": "Garygunn94", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 7, + "lines_added": 386, + "lines_deleted": 38, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T20:24:47Z", + "last_commit": "2026-10-09T15:05:24Z" + }, + { + "login": "vbasky", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 11, + "lines_added": 1039, + "lines_deleted": 174, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T00:12:26Z", + "last_commit": "2026-10-10T05:22:24Z" + }, + { + "login": "DWAA1660", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 67, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T00:58:22Z", + "last_commit": "2026-10-09T00:58:22Z" + }, + { + "login": "hamada147", + "people_entry": true, + "github_name": "Ahmed Moussa", + "prs": 1, + "commits": 1, + "lines_added": 27, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T08:49:05Z", + "last_commit": "2026-10-09T08:49:05Z" + }, + { + "login": "salignatmoandal", + "people_entry": true, + "github_name": "where's Mawen?", + "prs": 2, + "commits": 3, + "lines_added": 119, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T19:03:36Z", + "last_commit": "2026-10-09T19:24:17Z" + }, + { + "login": "grovebit", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 218, + "lines_deleted": 15, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:45:39Z", + "last_commit": "2026-10-10T01:34:26Z" + }, + { + "login": "coygeek", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 125, + "lines_deleted": 91, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T01:28:07Z", + "last_commit": "2026-10-10T01:28:07Z" } ], "models": [ @@ -27,9 +196,18 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 47, - "lines_added": 78091, - "lines_deleted": 1927 + "commits": 73, + "lines_added": 82279, + "lines_deleted": 2544 + }, + { + "id": "anthropic/claude-haiku/5.5", + "company": "Anthropic", + "model": "Claude Haiku", + "version": "5.5", + "commits": 5, + "lines_added": 368, + "lines_deleted": 35 } ] } diff --git a/contributors/people.toml b/contributors/people.toml index 5079ae9..bc8bd31 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -35,3 +35,8 @@ sync_github_name = false [people.salignatmoandal] real_name = "Mawen Salignat-Moandal" sync_github_name = true + +[people.vbasky] +real_name = "Vikram Bhaskaran" +display_name = "Vikram Bhaskaran" +sync_github_name = false diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 1b38dcc..19fe91a 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -127,11 +127,20 @@ impl Server { } "batch" => { let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false); - let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default(); + let Some(calls) = args.get("calls").and_then(Value::as_array) else { + return error_result("`calls` must be an array"); + }; let mut out = Vec::new(); let mut failed = false; for c in calls.iter().take(1000) { - let cmd = c.get("command").and_then(Value::as_str).unwrap_or(""); + let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else { + out.push(json!({"command": "", "ok": false, "error": "`command` required"})); + failed = true; + if !keep { + break; + } + continue; + }; let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({}))); match r { Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})), @@ -144,7 +153,9 @@ impl Server { } } } - if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } + // Any failure sets isError so agents that only check that flag notice; + // keep_going still runs the rest and the payload lists every call. + if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } } "inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})), "new_session" => self.exec("session.new", args.clone()), diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 04fe256..3636cfe 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -135,3 +135,39 @@ fn errors_are_reported_not_panics() { let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}})); assert_eq!(r["result"]["isError"], true); } + +#[test] +fn batch_keep_going_still_sets_is_error_on_partial_failure() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc( + &mut s, + 1, + "tools/call", + json!({"name": "batch", "arguments": {"keep_going": true, "calls": [ + {"command": "track.new", "params": {"count": 1, "name": "Kept"}}, + {"command": "mix.volume", "params": {"track": "Nope", "db": -6}}, + {"command": "track.new", "params": {"count": 1, "name": "After"}}, + ]}}), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + let results: Value = serde_json::from_str(text).unwrap(); + let arr = results.as_array().unwrap(); + assert_eq!(arr.len(), 3, "{results}"); + assert_eq!(arr[0]["ok"], true); + assert_eq!(arr[1]["ok"], false); + assert_eq!(arr[2]["ok"], true); + let sess = session(&mut s); + assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept")); + assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After")); +} + +#[test] +fn batch_rejects_non_array_calls() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("`calls` must be an array"), "{text}"); + assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0); +} diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 6cbac6f..16aaf33 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -71,8 +71,9 @@ unsafe fn read_cstr(p: *const c_char) -> Option { } /// Reads a fixed-size `char[N]` name buffer, stopping at the first NUL or the end of the array. +/// `c_char` is `i8` on x86 and `u8` on ARM/RISC-V Linux; the byte reinterpretation is the same for both. fn read_name(buf: &[c_char]) -> String { - let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect(); + let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| u8::from_ne_bytes(c.to_ne_bytes())).collect(); String::from_utf8_lossy(&bytes).into_owned() } diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index ac26a30..e2abfca 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result { nc.name = new_name; nc.content = ClipContent::Audio { source: src, offset: 0 }; nc.length = i64::try_from(frames).unwrap_or(nc.length); - nc.gain_db = 0.0; - nc.gain_env.clear(); + // Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain, + // the same way its fades stay editable. Other processes bake a new file at unity gain. + if !matches!(name.as_str(), "invert" | "duplicate") { + nc.gain_db = 0.0; + nc.gain_env.clear(); + } if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { pl.clips.retain(|c| c.id != id); } @@ -122,3 +126,43 @@ fn short(n: &str) -> String { let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join(""); s.chars().take(8).collect() } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Session, TrackKind}; + + fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + let mut c = Clip::audio(id, "tone", src, 0, 0, 64); + c.gain_db = -6.0; + c.gain_env = vec![(0, 0.0), (32, -12.0)]; + crate::edit::place_clip(&mut s, t, c); + s.edit.selected_clips = vec![id]; + (crate::Engine::new(s), id) + } + + #[test] + fn invert_and_duplicate_keep_clip_gain() { + let (mut e, id) = gained_clip(); + e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db); + assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]); + let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; + assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}"); + + let (mut e, id) = gained_clip(); + e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db); + assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]); + let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; + assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); + } +} diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index cca8102..74dfd8d 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -362,4 +362,69 @@ mod tests { assert_eq!(c.length, 18_000); assert_ne!(c.source(), Some(src)); } + + fn peak_from(ch: &[f32], start: usize) -> f32 { + ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs())) + } + + fn max_abs_diff(a: &[Vec], b: &[Vec]) -> f32 { + a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max) + } + + /// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result. + #[test] + fn render_preserves_stretched_audio() { + for command in ["clip.gain_render", "clip.effects_render"] { + for ratio in [0.5_f64, 1.0, 2.0] { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600)); + s.edit.selected_clips = vec![id]; + let mut e = Engine::new(s); + e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap(); + e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap(); + if command == "clip.effects_render" { + e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap(); + } + let range = e.session().find_clip(id).unwrap().1.range(); + let before = soundcraft_mix::render_clips(e.session(), t, range); + e.execute(command, &json!({"clip": id.0})).unwrap(); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch); + assert_eq!(c.gain_db, 0.0); + let after = soundcraft_mix::render_clips(e.session(), t, range); + let diff = max_abs_diff(&before, &after); + assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}"); + if ratio < 1.0 { + let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0); + assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}"); + } + } + } + + // Varispeed on a non-elastic track does not change the clip length. Render still matches it. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600)); + s.edit.selected_clips = vec![id]; + let mut e = Engine::new(s); + e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap(); + e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap(); + let range = e.session().find_clip(id).unwrap().1.range(); + let before = soundcraft_mix::render_clips(e.session(), t, range); + e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap(); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert_eq!(c.length, 9_600); + assert!((c.stretch - 1.0).abs() < 1e-9); + let after = soundcraft_mix::render_clips(e.session(), t, range); + assert!(max_abs_diff(&before, &after) < 1e-5); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9d8d7f0..dd49ed4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,7 +25,9 @@ pub fn specs() -> Vec { p, "edit.clear_clip_gain" )), - cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)), + cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{tracks?, start?, end?}", has_range, |e, p| { + super::edit_more::cut_all_automation(e, p) + }), cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)), cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation( e, diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index d27bb97..c5c1082 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -277,6 +277,10 @@ fn nonempty_range(e: &Engine, p: &Value, id: &str) -> Result { if r.is_empty() { Err(bad(id, "the selection is empty")) } else { Ok(r) } } +pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result { + auto_copy(e, p, Lanes::All, true, "edit.cut_all_automation") +} + fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result { let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; @@ -663,8 +667,10 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result { let mut moved = 0; for (id, _) in order { let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; - let others = - s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id && !ids.contains(&x.id)).cloned().collect::>()).unwrap_or_default(); + // Selected neighbours are real destinations. Ignoring them made Snap to Next + // pile every selected clip onto the same later clip, and Snap to Previous + // leave the gap between the selected clips in place. + let others = s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id).cloned().collect::>()).unwrap_or_default(); let delta = if next { others.iter().filter(|x| x.start >= c.end()).map(|x| x.start).min().map(|st| st - c.end()) } else { @@ -982,6 +988,34 @@ mod tests { assert_eq!(starts(&e, t), vec![0, 900, 1000]); } + #[test] + fn cut_all_automation_can_be_pasted() { + let (mut e, t, _) = session_with_clips(&[(0, 1000)]); + e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 100, "value": -6.0})).unwrap(); + e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 400, "value": -12.0})).unwrap(); + let cut = e.execute("edit.cut_all_automation", &json!({"start": 50, "end": 250})).unwrap(); + assert!(cut["lanes"].as_u64().unwrap() >= 1, "{cut}"); + assert!(e.clipboard.automation.iter().any(|lanes| !lanes.is_empty())); + let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!(lane.points.iter().all(|p| p.at != 100), "the cut point is gone: {:?}", lane.points); + e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap(); + let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!(vol.points.iter().any(|p| p.at >= 1000 && (p.value + 6.0).abs() < 1e-2), "paste wrote nothing at -6 dB: {:?}", vol.points); + } + + #[test] + fn snap_selected_neighbours_keeps_both_clips() { + let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]); + e.execute("edit.snap_next", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips().len(), 3); + assert_eq!(starts(&e, t), vec![800, 900, 1000]); + + let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]); + e.execute("edit.snap_previous", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips().len(), 3); + assert_eq!(starts(&e, t), vec![0, 100, 1000]); + } + #[test] fn copy_and_paste_automation_only() { let (mut e, t, _) = session_with_clips(&[(0, 1000)]); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 796ad8f..30145e9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -27,9 +27,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string(); - let keep = e.path.clone(); - let written = crate::io::save_session(e, &path)?; - e.path = keep; + let written = crate::io::save_session_copy(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| { @@ -98,7 +96,8 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); - let bytes = crate::io::export_midi(e)?; + let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None }; + let bytes = crate::io::export_midi(e, only.as_deref())?; std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) }), @@ -234,6 +233,17 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result> { selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track")) } +/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error. +fn bounce_bus(e: &Engine, p: &Value) -> Result> { + let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + if name.eq_ignore_ascii_case("main") { + return Ok(None); + } + e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`"))) +} + /// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix. fn fold_down_param(p: &Value, id: &str) -> Result { match str_param(p, "fold_down").map(str::trim) { @@ -260,7 +270,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }; let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; - let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; + let bus = bounce_bus(e, p)?; + let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?; std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok( json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), @@ -365,6 +376,53 @@ mod tests { assert!(std::fs::remove_dir_all(&dir).is_ok()); } + #[test] + fn bounce_source_renders_the_named_bus() { + let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let wav = tone_wav(&dir, "tone.wav"); + let mut e = Engine::default(); + e.execute("file.import_audio", &json!({"path": wav})).unwrap(); + let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo); + e.session_mut().add_bus("Empty", ChannelFormat::Stereo); + let track = e.session().tracks[0].id; + { + let tr = e.session_mut().track_mut(track).unwrap(); + let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem)); + send.level_db = -12.0; + tr.mixer.sends[0] = Some(send); + } + let bounce = |e: &mut Engine, name: &str, source: Option<&str>| { + let path = dir.join(name); + let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false}); + if let Some(source) = source { + params["source"] = json!(source); + } + let value = e.execute("file.bounce_mix", ¶ms).unwrap(); + let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0); + (std::fs::read(&path).unwrap(), peak) + }; + let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None); + let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main")); + let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM")); + let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty")); + assert_eq!(main_bytes, named_main); + assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}"); + assert!(main_peak > -20.0, "{main_peak}"); + assert_ne!(stem_bytes, main_bytes); + assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}"); + assert_ne!(empty_bytes, main_bytes); + assert!(empty_peak < -100.0, "{empty_peak}"); + let missing = dir.join("missing.wav"); + let err = e + .execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false})) + .unwrap_err(); + assert!(err.to_string().contains("unknown bus"), "{err}"); + assert!(!missing.exists()); + assert!(std::fs::remove_dir_all(&dir).is_ok()); + } + fn remove_media(dir: &std::path::Path) { for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { let p = entry.path(); @@ -390,4 +448,177 @@ mod tests { assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); let _ = std::fs::remove_dir_all(&dir); } + fn tone_engine() -> (Engine, u64) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); + let track = e.session().tracks[0].id; + let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800)); + e.session_mut().edit.selected_tracks = vec![track]; + (e, track.0) + } + + #[test] + fn same_named_clips_export_as_separate_files() { + let (mut e, track) = tone_engine(); + let first = e.session().tracks[0].clips()[0].id; + let src = e.session().tracks[0].clips()[0].source().unwrap(); + let second = e.session_mut().new_clip_id(); + crate::edit::place_clip( + e.session_mut(), + soundcraft_model::TrackId(track), + soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800), + ); + for id in [first, second] { + e.session_mut().find_clip_mut(id).unwrap().name = "Take".into(); + } + let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap(); + assert_eq!(n["written"], 2); + let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + wavs.sort(); + assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } + + fn midi_engine() -> Engine { + use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + let mut s = Session::default(); + let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys")); + let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums")); + let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room")); + let mut keys_seq = Sequence::default(); + keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 }); + let mut drum_seq = Sequence::default(); + drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 }); + drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 }); + let a = s.new_clip_id(); + let b = s.new_clip_id(); + crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq)); + crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq)); + Engine::new(s) + } + + fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> { + let smf = soundcraft_midi::read_smf(bytes).unwrap(); + let mut out = Vec::new(); + for t in &smf.tracks { + for c in &t.sequence.ctrls { + if let soundcraft_midi::CtrlKind::Cc(n) = c.kind { + out.push((t.name.clone(), n, c.value)); + } + } + } + out + } + + #[test] + fn midi_export_keeps_controllers_and_honours_tracks() { + let mut e = midi_engine(); + let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let all = dir.join("all.mid"); + e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&all).unwrap()); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}"); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}"); + assert!(ctrls.iter().all(|(n, _, _)| n != "Room")); + + let keys = dir.join("keys.mid"); + e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&keys).unwrap()); + assert_eq!(ctrls.len(), 1, "{ctrls:?}"); + assert_eq!(ctrls[0].0, "Keys"); + + let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err(); + assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn import_surround_creates_a_matching_track() { + let channels: Vec> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels }; + let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap(); + let mut e = Engine::default(); + let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap(); + assert_eq!(v["channels"], 6); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51); + let src = e.session().sources[0].id; + let audio = e.session().pool.get(src).unwrap(); + assert_eq!(audio.buffer.channels.len(), 6); + assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3); + + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap(); + let id = e.session().tracks[0].id; + crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51); + assert_eq!(e.session().tracks[0].clips().len(), 1); + } + + #[test] + fn repeated_consolidate_keeps_distinct_audio_files() { + let (mut e, track) = tone_engine(); + e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap(); + let cid = e.session().tracks[0].clips()[0].id; + e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap(); + e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap(); + let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect(); + assert_eq!(consolidated.len(), 2, "{consolidated:?}"); + assert_ne!(consolidated[0], consolidated[1]); + + let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Tone.scraft"); + e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut opened = Engine::default(); + opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect(); + assert_eq!(files.len(), 2, "{:?}", opened.session().sources); + let a = std::fs::read(&files[0]).unwrap(); + let b = std::fs::read(&files[1]).unwrap(); + assert_ne!(a, b, "the second consolidation replaced the first file"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_copy_leaves_the_open_session_alone() { + let (mut e, _) = tone_engine(); + let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("Original.scraft"); + e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap(); + e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap(); + assert!(e.is_dirty()); + let copy_dir = dir.join("copy"); + std::fs::create_dir_all(©_dir).unwrap(); + let copy = copy_dir.join("Copy.scraft"); + e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref())); + assert_eq!(e.session().name, "Original"); + assert!(e.is_dirty()); + assert_eq!(e.session().tracks.len(), 2); + + let mut from_original = Engine::default(); + from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap(); + assert_eq!(from_original.session().tracks.len(), 1); + let mut from_copy = Engine::default(); + from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(from_copy.session().tracks.len(), 2); + assert!(from_copy.session().track_by_name("Extra").is_some()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 03574b2..3a01d3d 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -534,25 +534,95 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result { Ok(json!({})) } +/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds. +fn env_db(env: &[(i64, f32)], at: i64) -> f32 { + let mut prev: Option<(i64, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?; let r = range_param(e, "automation.volume_to_clip_gain", p)?; let s = e.session_mut(); for t in &tracks { let Some(tr) = s.track_mut(*t) else { continue }; - let lane = tr.lane(&AutoParam::Volume).cloned(); + let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue }; let base = tr.mixer.volume_db; - let Some(lane) = lane else { continue }; + let delta = |at: i64| lane.value_at(at, base) - base; if let Some(pl) = tr.playlist_mut() { for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { - let mut env: Vec<(i64, f32)> = - lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect(); - env.insert(0, (0, lane.value_at(c.start, base) - base)); - env.push((c.length, lane.value_at(c.end(), base) - base)); + let sel0 = r.start.max(c.start); + let sel1 = r.end.min(c.end()); + if sel1 <= sel0 { + continue; + } + let rel = |at: i64| at - c.start; + let rel0 = rel(sel0); + let rel1 = rel(sel1); + // Later writes win, so the selection anchors replace any point they land on. + let mut pts: Vec<(i64, f32)> = + c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + if rel0 > 0 { + pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); + } + pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { + let at = rel(pt.at); + pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); + } + // The clip's own gain points inside the selection keep their shape, offset by the lane. + for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); + } + if sel1 - 1 > sel0 { + let at = rel(sel1 - 1); + pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + } + if sel1 < c.end() { + pts.push((rel1, env_db(&c.gain_env, rel1))); + } + pts.sort_by_key(|p| p.0); + let mut env = Vec::new(); + for p in pts { + if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + env.pop(); + } + env.push(p); + } c.gain_env = env; } } - tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end); + // Clearing the lane is only safe when no audio plays outside the selection; otherwise the + // held edge values outside would fall back to the fader level. + let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end); + let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); + let lane_m = tr.lane_mut(&AutoParam::Volume); + if covers_all { + lane_m.clear_range(r.start, r.end); + } else { + // Hold the fader across the selection and anchor the samples on either side, so the + // curve outside does not interpolate through the gap or keep the old level inside. + let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base)); + let after = lane.value_at(r.end, base); + lane_m.clear_range(r.start, r.end); + if let Some(v) = before { + lane_m.set_point(r.start - 1, v); + } + lane_m.set_point(r.start, base); + if r.end - 1 > r.start { + lane_m.set_point(r.end - 1, base); + } + lane_m.set_point(r.end, after); + } } Ok(json!({})) } @@ -606,3 +676,119 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result { } Ok(json!({})) } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; + use soundcraft_time::Range; + use std::sync::Arc; + + fn rms_db(before: &[f32], after: &[f32]) -> f32 { + let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::() / xs.len().max(1) as f32; + 10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10() + } + + fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) { + let mut s = Session::default(); + let n = 8192; + let tone: Vec = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect(); + s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n)); + s.track_mut(t).unwrap().mixer.volume_db = -3.0; + for &(at, value) in points { + s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value); + } + s.edit.selected_tracks = vec![t]; + s.edit.selection = Range::new(2048, 6144); + (Engine::new(s), t) + } + + fn rendered(s: &Session) -> Vec { + soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default() + } + + fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) { + e.session_mut().edit.selection = Range::new(start, end); + e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap(); + } + + #[test] + fn partial_volume_to_clip_gain_keeps_each_region() { + let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)]; + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB"); + } + let tr = e.session().track(t).unwrap(); + assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5); + let lane = tr.lane(&AutoParam::Volume).unwrap(); + assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3); + assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3); + assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3); + + let (mut e, t) = tone_session(&[]); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(diff < 1e-5, "no lane changed a sample by {diff}"); + + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 0, 8193); + let after = rendered(e.session()); + let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(diff < 1e-4, "full selection changed a sample by {diff}"); + assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty())); + + for points in [ + [(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)], + [(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)], + ] { + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2048), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}"); + } + let delta = rms_db(&before[2048..6144], &after[2048..6144]); + assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}"); + } + } + + #[test] + fn lane_points_all_inside_the_selection_keep_the_outside_level() { + let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB"); + } + } + + #[test] + fn clip_gain_points_inside_the_selection_are_kept() { + let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]); + let clip = e.session().track(t).unwrap().clips()[0].id; + e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)]; + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB"); + } + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..18f1de6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -3,7 +3,7 @@ use crate::{Engine, EngineError, Result}; use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; -use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_time::{Range, Samples}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -11,6 +11,21 @@ use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. const MAX_IMPORT_SAMPLES: usize = 1 << 30; +/// A relative `Audio Files/` path no other source in `s` already uses. +fn unique_audio_rel(s: &Session, stem: &str) -> String { + let stem = sanitize_name(stem); + let mut path = format!("Audio Files/{stem}.wav"); + let mut n = 2u32; + while s.sources.iter().any(|src| src.path == path) { + path = format!("Audio Files/{stem}-{n}.wav"); + n = n.saturating_add(1); + if n > 10_000 { + break; + } + } + path +} + /// Register decoded audio as a new source. Resamples to the session rate if needed. pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId { let sr = s.sample_rate.hz(); @@ -20,10 +35,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio } let id = SourceId(s.alloc()); let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); + let stored = match path { + Some(p) => p.to_string(), + None => unique_audio_rel(s, &stem), + }; s.sources.push(Source { id, name: stem.clone(), - path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string), + path: stored, channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX), frames: buf.frames() as u64, sample_rate: sr, @@ -56,7 +75,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option Some(t) => t, None => { let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); - s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem)) + s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) } }; let cid = s.new_clip_id(); @@ -180,8 +199,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b Ok(out) } -/// Export all MIDI clips as a format-1 SMF. -pub fn export_midi(e: &Engine) -> Result> { +/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks. +pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { let s = e.session(); let mut smf = soundcraft_midi::Smf { format: 1, @@ -191,16 +210,25 @@ pub fn export_midi(e: &Engine) -> Result> { markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(), key_sigs: Vec::new(), }; - for t in s.tracks.iter().filter(|t| t.kind.is_midi()) { + let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect(); + if only.is_some() && chosen.is_empty() { + return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into())); + } + for t in chosen { let mut seq = soundcraft_midi::Sequence::default(); for c in t.clips() { if let ClipContent::Midi { sequence } = &c.content { let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); for n in &sequence.notes { let mut n = *n; - n.start += base; + n.start = n.start.saturating_add(base); seq.notes.push(n); } + for ev in &sequence.ctrls { + let mut ev = *ev; + ev.tick = ev.tick.saturating_add(base); + seq.ctrls.push(ev); + } } } seq.sort(); @@ -217,11 +245,23 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) /// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV /// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { + bounce_source(e, r, opts, normalize, fold_stereo, None) +} + +/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named. +pub fn bounce_source( + e: &Engine, + r: Range, + opts: &EncodeOptions, + normalize: bool, + fold_stereo: bool, + bus: Option, +) -> Result<(Vec, (f32, f32, f32))> { let s = e.session(); if r.len() > s.sample_rate.samples(4.0 * 3600.0) { return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); } - let (mut ch, fmt) = render_main(s, r, fold_stereo); + let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus); if normalize { soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); } @@ -238,8 +278,19 @@ pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: /// The main mix over `r` and its format (folded to stereo on request). fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { - let ch = soundcraft_mix::render_range(s, r, 1024); - let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + render_bounce(s, r, fold_stereo, None) +} + +/// The main mix, or one bus, over `r`. +fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) { + let (ch, fmt) = if let Some(id) = bus { + let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo); + (soundcraft_mix::render_bus(s, id, r, 1024), fmt) + } else { + let ch = soundcraft_mix::render_range(s, r, 1024); + let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + (ch, fmt) + }; if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } } @@ -379,6 +430,9 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result< cl.content = ClipContent::Audio { source: src, offset: 0 }; cl.gain_db = 0.0; cl.gain_env.clear(); + // The new source is the clip as it already sounds, stretch included. + // Keeping the old ratio would play that audio through the stretch again. + cl.stretch = 1.0; } Ok(true) } @@ -432,7 +486,16 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> let audio = soundcraft_mix::render_clips(s, t, c.range()); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?; - let path = Path::new(dir).join(format!("{}.wav", sanitize_name(&c.name))); + let stem = sanitize_name(&c.name); + let mut path = Path::new(dir).join(format!("{stem}.wav")); + let mut k = 2u32; + while path.exists() { + path = Path::new(dir).join(format!("{stem}-{k}.wav")); + k = k.saturating_add(1); + if k > 10_000 { + return Err(EngineError::Io(format!("{dir}: too many files named {stem}"))); + } + } std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; n += 1; } @@ -462,6 +525,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result { write_session(e.session(), path, true).map(|(_, n, _)| n) } +/// File › Save Session Copy In: a normal session folder that does not rename, retarget or +/// clean the session that stays open. +pub fn save_session_copy(e: &Engine, path: &str) -> Result { + write_session(e.session(), path, false).map(|(_, n, _)| n) +} + /// Write `session` to `path`; returns the session with updated media paths, the number of audio /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { @@ -471,10 +540,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); let mut written = 0; - for src in &mut s.sources { + let shared_paths: Vec = + s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); + for (i, src) in s.sources.iter_mut().enumerate() { let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); - if exists_rel && !src.unsaved { + let shared = shared_paths.get(i).copied().unwrap_or(false); + if exists_rel && !src.unsaved && !shared { continue; } let Some(audio) = s.pool.get(src.id) else { continue }; @@ -490,7 +562,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess } } else { let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path) { + while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) { target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); k += 1; if k > 999 { @@ -613,6 +685,28 @@ mod save_tests { assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn save_gives_sources_that_share_a_path_their_own_files() { + let mut s = Session::default(); + let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] }; + add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav); + add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav); + let mut e = Engine::new(s); + let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Shared.scraft"); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect(); + assert_eq!(paths.len(), 2); + assert_ne!(paths[0], paths[1], "{paths:?}"); + let a = std::fs::read(dir.join(&paths[0])).unwrap(); + let b = std::fs::read(dir.join(&paths[1])).unwrap(); + assert_ne!(a, b); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3662cde..50d82e4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,8 @@ pub type Result = std::result::Result; /// Requests for the transport/audio engine, drained by the host app each frame. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub enum TransportRequest { + /// Stop the old session's transport without applying stop-time edits to the new document. + ResetSession, Play, Stop, TogglePlay, @@ -158,6 +160,9 @@ impl Engine { /// Replace the whole document (open / new); clears history. pub fn replace_session(&mut self, s: Session) { + self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() }; + self.transport_requests.clear(); + self.transport_requests.push(TransportRequest::ResetSession); self.doc = Arc::new(s); self.undo.clear(); self.redo.clear(); diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/session_transport.rs new file mode 100644 index 0000000..73048e1 --- /dev/null +++ b/crates/engine/tests/session_transport.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::{Engine, TransportRequest, TransportStatus}; +use soundcraft_model::Session; +use soundcraft_time::Range; + +#[test] +fn replacing_session_resets_status_and_discards_old_transport_requests() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 }; + engine.execute("transport.play", &json!({"from": 96_000})).unwrap(); + engine.execute("transport.record", &json!({})).unwrap(); + let mut session = Session::default(); + session.edit.selection = Range::point(12_000); + engine.replace_session(session); + assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() }); + assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]); + assert!(!engine.is_dirty()); +} + +#[test] +fn failed_new_session_keeps_the_current_transport() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() }; + engine.execute("transport.play", &json!({})).unwrap(); + let status = engine.transport; + let requests = engine.transport_requests.clone(); + assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err()); + assert_eq!(engine.transport, status); + assert_eq!(engine.transport_requests, requests); +} diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs index 345d3e8..36c18a0 100644 --- a/crates/midi/src/smf.rs +++ b/crates/midi/src/smf.rs @@ -267,10 +267,8 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; match kind { 0x2F => break, // End of Track - 0x03 => { - if name.is_none() { - name = Some(String::from_utf8_lossy(data).into_owned()); - } + 0x03 if name.is_none() => { + name = Some(String::from_utf8_lossy(data).into_owned()); } 0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())), 0x51 => { diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/smf.rs index 5b89e34..f1861d8 100644 --- a/crates/midi/tests/smf.rs +++ b/crates/midi/tests/smf.rs @@ -91,6 +91,24 @@ fn round_trip() { assert_eq!(write_smf(&again).unwrap(), bytes); } +#[test] +fn first_track_name_wins_and_ignored_meta_preserves_following_notes() { + let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat(); + for first in [&b"First"[..], &b""[..], &[0xFF][..]] { + let body = [ + meta(0x03, first), + meta(0x03, b"Second"), + meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored. + vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first)); + assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]); + } +} + #[test] fn write_rejects_bad_values() { let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() }; diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..79c2fc8 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -293,6 +293,11 @@ pub struct MixEngine { order: Vec, /// Current total compensation delay (samples) of the mix. pub latency: usize, + /// Delay already applied to audio written onto busses: the slowest track insert. + /// A bus render drops this so the bus lines up with the timeline. The extra delay + /// that lines direct outputs up with aux returns stays in `latency` and is removed + /// only from the main mix. + track_latency: usize, /// Live input for the next block (planar), set by the audio host for input monitoring. pub input: Vec>, /// Transport stopped: only monitored inputs sound (no clips). @@ -308,6 +313,8 @@ pub struct MixEngine { adopt: HashMap<(TrackId, usize), PreparedInstance>, /// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]). retired: Vec>, + /// Ownership workspace reused for parallel strip processing. + work: Vec<(usize, Strip)>, } /// What `sync` needs to fill a slot. @@ -363,6 +370,7 @@ impl MixEngine { synced: (0, 0, 0), order: Vec::new(), latency: 0, + track_latency: 0, input: Vec::new(), monitor_only: false, recording: false, @@ -370,6 +378,7 @@ impl MixEngine { external: false, adopt: HashMap::new(), retired: Vec::new(), + work: Vec::new(), } } @@ -568,16 +577,17 @@ impl MixEngine { } let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive); let order = std::mem::take(&mut self.order); - let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); + let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); // Phase 1: independent strips (audio, instrument, MIDI) in parallel. - let mut work: Vec<(&Track, Strip)> = order - .iter() - .filter_map(|&i| s.tracks.get(i)) - .filter(live) - .filter(|t| !is_aux(t)) - .filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st))) - .collect(); + let mut work = std::mem::take(&mut self.work); + work.clear(); + for &i in &order { + let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue }; + if let Some(st) = self.strips.remove(&t.id) { + work.push((i, st)); + } + } for (_, st) in work.iter_mut() { if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); @@ -585,16 +595,30 @@ impl MixEngine { } let busses = std::mem::take(&mut self.busses); let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo)); + run_strips(&mut work, |(i, st)| { + if let Some(t) = s.tracks.get(*i) { + process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); + } + }); self.busses = busses; - // Delay compensation: align every strip to the slowest; direct-to-main outputs also wait - // for the slowest aux return (aux latencies are from the previous block; they are stable). + // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait + // for the slowest aux return. Aux latency has to be read before those outputs are routed, + // or the first block (and a one-block bounce probe) still sees zero. let pdc = s.edit.delay_compensation; + if pdc { + for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) { + if let Some(st) = self.strips.get_mut(&t.id) { + note_insert_latency(t, st, pos); + } + } + } let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; + self.track_latency = lmax_t; self.latency = lmax_t + lmax_a; - for (t, st) in work.iter_mut() { + for (i, st) in work.iter_mut() { + let Some(t) = s.tracks.get(*i) else { continue }; let ch = st.buf.len(); st.align.set(lmax_t.saturating_sub(st.latency), ch); st.align.process(&mut st.buf, frames); @@ -604,15 +628,20 @@ impl MixEngine { } st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch); } - for (t, st) in work.iter_mut() { - self.route_strip(t, st, pos, frames); + for (i, st) in work.iter_mut() { + if let Some(t) = s.tracks.get(*i) { + self.route_strip(t, st, pos, frames); + } } - for (t, st) in work { - self.strips.insert(t.id, st); + for (i, st) in work.drain(..) { + if let Some(t) = s.tracks.get(i) { + self.strips.insert(t.id, st); + } } + self.work = work; let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); // Phase 2: auxes in dependency order (they read busses). - for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) { + for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) { let Some(mut st) = self.strips.remove(&t.id) else { continue }; if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); @@ -663,7 +692,7 @@ impl MixEngine { // through any track, send or insert. Only the playback engine sets `metronome`. // Not while monitoring only (transport stopped): the position does not advance there, so the // start of the same beat would repeat every block as a buzz. - if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) { + if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) { click::render(s, pos, frames, &mut self.main); } // Output: min(out.len(), main channels) channels; any further output channels are silent. @@ -679,6 +708,24 @@ impl MixEngine { self.main_meter.measure(&self.main, frames, 0.0); } + /// Copy one rendered block of `bus` into `out`. A missing bus stays silent. + fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec]) { + for o in out.iter_mut() { + for x in o.iter_mut().take(frames) { + *x = 0.0; + } + } + let Some(src) = self.busses.get(&bus) else { return }; + for (o, c) in out.iter_mut().zip(src.buf.iter()) { + for i in 0..frames { + let v = c.get(i).copied().unwrap_or(0.0); + if let Some(d) = o.get_mut(i) { + *d = if v.is_finite() { v } else { 0.0 }; + } + } + } + } + /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr); @@ -876,8 +923,11 @@ fn process_strip( d.extend_from_slice(src.get(..frames).unwrap_or(&[])); } } + // Endpoints are the first and last sample of this block. The next block starts on its own + // first sample, so a breakpoint there does not ramp the block before it. + let last = pos.saturating_add(frames.saturating_sub(1) as i64); let mut v0 = volume_db_at(t, pos); - let mut v1 = volume_db_at(t, pos + frames as i64); + let mut v1 = volume_db_at(t, last); // Trim automation (an offset on top of the volume curve). if let Some(l) = t .automation @@ -886,14 +936,14 @@ fn process_strip( .filter(|l| !l.points.is_empty()) { v0 += l.value_at(pos, 0.0); - v1 += l.value_at(pos + frames as i64, 0.0); + v1 += l.value_at(last, 0.0); } // VCA master: its fader offsets the member's, and its mute mutes the member. if let Some(vid) = t.mixer.vca && let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca) { v0 += volume_db_at(vca, pos); - v1 += volume_db_at(vca, pos + frames as i64); + v1 += volume_db_at(vca, last); if vca.mixer.mute { strip.muted = true; } @@ -902,7 +952,7 @@ fn process_strip( for c in strip.buf.iter_mut() { for i in 0..frames { if let Some(x) = c.get_mut(i) { - *x *= lerp_gain(g0, g1, i, frames); + *x *= lerp_gain(g0, g1, i, frames.saturating_sub(1)); } } } @@ -1202,10 +1252,12 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut while i < i1 { let seg_end = (i + STEP).min(i1); let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start); - let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start); + // The last sample of this step, not the first sample of the next one, so a breakpoint + // on the boundary does not ramp the audio before it. + let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start); let g0 = clip.gain_at(rel0); let g1 = clip.gain_at(rel1.min(clip.length)); - let n = (seg_end - i) as f32; + let n = (seg_end - i).saturating_sub(1).max(1) as f32; for k in i..seg_end { let rel = pos.saturating_add(k as i64).saturating_sub(clip.start); let v = if (stretch - 1.0).abs() < 1e-9 { @@ -1278,6 +1330,20 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } } +/// Record insert latency without processing audio, so this block can compensate for it. +fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { + let mut latency = 0usize; + for (i, ins) in t.mixer.inserts.iter().enumerate() { + let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue }; + if !ins.active || ins.bypass { + continue; + } + apply_params(slot, ins, t, i, pos); + latency = latency.saturating_add(slot.plugin.latency()); + } + strip.latency = latency; +} + fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { let slot_u8 = u8::try_from(i).unwrap_or(0); let automated = t.mixer.automation_mode.reads() @@ -1640,6 +1706,46 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { out } +/// Offline render of one bus over `range` (planar, the bus format's channel count). +/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. +/// An unknown bus is silent. +pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { + let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); + if nch == 0 { + return Vec::new(); + } + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch); + let latency = { + let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = probe.max_block(); + probe.render(s, range.start, b.min(len.max(1)), &mut []); + probe.track_latency + }; + let total = len.saturating_add(latency); + let mut out = vec![vec![0.0f32; total]; nch]; + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = eng.max_block(); + let mut tmp = vec![vec![0.0f32; b]; nch]; + let mut done = 0usize; + while done < total { + let n = (total - done).min(b); + eng.render(s, range.start + done as i64, n, &mut []); + eng.copy_bus(bus, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(sr); + } + } + done += n; + } + if latency > 0 { + for c in &mut out { + c.drain(..latency.min(c.len())); + } + } + out +} + /// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; /// Ambisonics play their W channel). Mono/stereo input is returned unchanged. pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { @@ -1879,6 +1985,20 @@ mod tests { assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]); } + #[test] + fn render_bus_is_the_bus_not_the_main_mix() { + let (mut s, t) = session_with_dc(0.5, 4800); + let stem = s.add_bus("Stem", ChannelFormat::Stereo); + let empty = s.add_bus("Empty", ChannelFormat::Stereo); + s.track_mut(t).unwrap().mixer.output = Route::Bus(stem); + let main = render_range(&s, Range::new(0, 4800), 512); + let bus = render_bus(&s, stem, Range::new(0, 4800), 512); + let silent = render_bus(&s, empty, Range::new(0, 4800), 512); + assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]); + assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]); + assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6))); + } + #[test] fn volume_automation_ramps() { let (mut s, t) = session_with_dc(0.5, 48_000); @@ -2057,6 +2177,53 @@ mod tests { assert_ne!(argmax(&out[0]), argmax(&out[1])); } + fn impulse_at(ch: &[f32]) -> Vec { + ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect() + } + + /// An aux insert's lookahead is part of the mix latency on the first block, not the block after. + #[test] + fn delay_compensation_aligns_an_aux_insert() { + let mut s = Session::default(); + let n = 8192; + let mut left = vec![0.0f32; n]; + for at in [0usize, 2048, 8000] { + left[at] = 0.25; + } + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] }))); + let bus = s.add_bus("Test Bus", ChannelFormat::Stereo); + let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source")); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return")); + let cid = s.new_clip_id(); + s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64)); + s.track_mut(source).unwrap().mixer.output = Route::Bus(bus); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024); + let control = render(&s, 0, n as i64); + assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]); + assert_eq!(impulse_at(&control[1]), impulse_at(&control[0])); + + let mut comp = Insert::new("compressor"); + comp.params.insert("lookahead".into(), 5.0); + comp.params.insert("ratio".into(), 1.0); + s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone()); + let on_source = render(&s, 0, n as i64); + assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0])); + + s.track_mut(source).unwrap().mixer.inserts[0] = None; + s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp); + let on_aux = render(&s, 0, n as i64); + assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce"); + assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0])); + assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]); + let partial = render(&s, 2048, 3072); + assert_eq!(impulse_at(&partial[0]), vec![0]); + + s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0); + let zero = render(&s, 0, n as i64); + assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0])); + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); @@ -2085,6 +2252,21 @@ mod tests { assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + #[test] + fn strip_workspace_keeps_its_allocation_between_blocks() { + let mut s = Session::default(); + for _ in 0..6 { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + } + let mut eng = MixEngine::new(48_000.0, 512); + let mut out = vec![vec![0.0; 512]; 2]; + eng.render(&s, 0, 512, &mut out); + let capacity = eng.work.capacity(); + assert!(capacity >= 6); + eng.render(&s, 512, 512, &mut out); + assert_eq!(eng.work.capacity(), capacity); + } + #[test] fn stopped_monitoring_does_not_click() { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..b24b251 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -86,7 +86,6 @@ struct AudioState { playing: bool, end: Option, looped: Option, - block: Vec>, /// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index. pending: Vec>, /// Stereo fold-down gains per main channel (used when the device has fewer channels). @@ -122,7 +121,6 @@ impl AudioState { playing: false, end: None, looped: None, - block: vec![vec![0.0; BLOCK]; nch], pending: vec![vec![0.0; BLOCK]; nch], pending_len: 0, read: 0.0, @@ -151,8 +149,7 @@ impl AudioState { self.mix.adopt(instances); // The main format changed (a document edit, not steady state): resize. let nch = main_channels(&s); - if self.block.len() != nch { - self.block = vec![vec![0.0; BLOCK]; nch]; + if self.pending.len() != nch { self.pending = vec![vec![0.0; BLOCK]; nch]; self.pending_len = 0; self.read = 0.0; @@ -171,6 +168,8 @@ impl AudioState { } Cmd::Stop => { self.playing = false; + self.end = None; + self.looped = None; self.mix.reset(); } Cmd::Locate(at) => { @@ -197,8 +196,8 @@ impl AudioState { } /// Render the next block at the session rate into `pending`. - fn render_next(&mut self) { - let mut n = BLOCK; + fn render_next(&mut self, quantum: usize) { + let mut n = quantum.clamp(1, BLOCK); if let Some(l) = self.looped && self.pos >= l.end { @@ -209,6 +208,7 @@ impl AudioState { } else if let Some(e) = self.end { if self.pos >= e { self.playing = false; + self.end = None; self.shared.playing.store(false, Ordering::Relaxed); n = 0; } else { @@ -231,19 +231,9 @@ impl AudioState { } self.mix.monitor_only = !self.playing; self.mix.recording = self.recording; - self.mix.render(&self.session, self.pos, n, &mut self.block); + self.mix.render(&self.session, self.pos, n, &mut self.pending); self.send_garbage(); - for (d, s) in self.pending.iter_mut().zip(self.block.iter()) { - if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) { - dd.copy_from_slice(ss); - } - } self.pending_len = n; - if !self.playing { - return; - } - self.pos += n as i64; - self.shared.position.store(self.pos, Ordering::Relaxed); if let Ok(mut m) = self.shared.meters.try_lock() { m.main.copy_from(&self.mix.main_meter); for (k, v) in &self.mix.meters { @@ -255,6 +245,10 @@ impl AudioState { } } } + if self.playing { + self.pos += n as i64; + self.shared.position.store(self.pos, Ordering::Relaxed); + } } /// Fill an interleaved device buffer. @@ -272,13 +266,15 @@ impl AudioState { } let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0; let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0); + let callback_frames = out.len() / channels; + let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK); for frame in out.chunks_mut(channels) { while self.read >= self.pending_len as f64 { self.read -= self.pending_len as f64; if !self.playing && !monitoring { break; } - self.render_next(); + self.render_next(quantum); if self.pending_len == 0 { break; } @@ -622,6 +618,11 @@ impl Player { self.shared.speed.store(v, Ordering::Relaxed); } + /// Playback rate last requested, 1.0 at full speed. + pub fn speed(&self) -> f32 { + self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0 + } + pub fn position(&self) -> Samples { self.shared.position.load(Ordering::Relaxed) } @@ -633,6 +634,21 @@ impl Player { pub fn meters(&self) -> MeterSnapshot { self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone() } + + /// Copy meters into reusable UI storage. Once track/channel counts are stable this performs + /// no allocation, unlike cloning a new snapshot every video frame. + pub fn copy_meters(&self, out: &mut MeterSnapshot) { + let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner); + copy_meter_snapshot(&source, out); + } +} + +fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { + out.tracks.retain(|id, _| source.tracks.contains_key(id)); + for (id, meter) in &source.tracks { + out.tracks.entry(*id).or_default().copy_from(meter); + } + out.main.copy_from(&source.main); } impl Side { @@ -730,6 +746,21 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre mod tests { use super::*; + #[test] + fn reusable_meter_snapshot_updates_and_drops_stale_tracks() { + let keep = TrackId(1); + let stale = TrackId(2); + let mut source = MeterSnapshot::default(); + source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 }); + source.main.peaks = vec![0.75, 0.5]; + let mut out = MeterSnapshot::default(); + out.tracks.insert(stale, StripMeter::default()); + copy_meter_snapshot(&source, &mut out); + assert_eq!(out.tracks.len(), 1); + assert_eq!(out.tracks[&keep], source.tracks[&keep]); + assert_eq!(out.main, source.main); + } + #[test] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); @@ -748,6 +779,23 @@ mod tests { assert!(!shared.playing.load(Ordering::Relaxed)); } + #[test] + fn short_device_callbacks_render_short_mix_blocks() { + let s = Arc::new(soundcraft_model::Session::default()); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + let mut out = vec![0.0f32; 2 * 64]; + st.fill(&mut out, 2); + assert_eq!(shared.position.load(Ordering::Relaxed), 64); + } + #[test] fn surround_main_maps_to_device_channels_or_folds_down() { use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; @@ -896,4 +944,30 @@ mod tests { assert!(p <= 1000, "position {p} should stay inside the loop"); assert!(shared.playing.load(Ordering::Relaxed)); } + + #[test] + fn armed_tracks_are_heard_and_metered_after_playback_stops() { + use soundcraft_model::{ChannelFormat, TrackKind}; + let mut s = soundcraft_model::Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.track_mut(t).unwrap().mixer.record_arm = true; + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0); + let ring = record::InputRing::new(1, 48_000); + tx.send(Cmd::Input(Arc::clone(&ring))).unwrap(); + tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap(); + let mut out = vec![0.0f32; 4096]; + st.fill(&mut out, 2); + assert!(!shared.playing.load(Ordering::Relaxed)); + ring.push(&[0.5; 4096]); + st.fill(&mut out, 2); + assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard"); + assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered"); + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0439b8b..66158bb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -4,10 +4,11 @@ description = "SoundCraft's egui user interface: Edit and Mix windows, transport version.workspace = true edition.workspace = true license.workspace = true -rust-version.workspace = true +rust-version = "1.95" repository.workspace = true [dependencies] +craft-ui.workspace = true soundcraft-time.workspace = true soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true @@ -21,7 +22,6 @@ soundcraft-engine.workspace = true soundcraft-playback.workspace = true soundcraft-video.workspace = true egui.workspace = true -dark-light.workspace = true serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index acb1d5a..073b823 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -1,9 +1,10 @@ //! Render the real UI offscreen (no window, no focus stealing) and save PNGs. //! -//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]` +//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH] [--system-theme light|dark|none]` //! //! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}` -//! or `{"steps": n}`. The demo session is loaded unless `--blank`. +//! or `{"steps": n}`. `{"system_theme": "light"}` (also `"dark"` or `"none"`) changes the +//! simulated OS appearance for subsequent frames. The demo session is loaded unless `--blank`. use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -11,11 +12,29 @@ use std::sync::mpsc; static READY: AtomicBool = AtomicBool::new(false); +fn system_theme(value: &str) -> Option> { + match value { + "light" => Some(Some(egui::Theme::Light)), + "dark" => Some(Some(egui::Theme::Dark)), + "none" => Some(None), + _ => None, + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into()); let script = args.get(1).filter(|a| !a.starts_with("--")).cloned(); let blank = args.iter().any(|a| a == "--blank"); + let appearance = if let Some(i) = args.iter().position(|a| a == "--system-theme") { + let Some(appearance) = args.get(i + 1).and_then(|value| system_theme(value)) else { + eprintln!("--system-theme requires light, dark or none"); + return; + }; + appearance + } else { + None + }; let (w, h) = args .iter() .position(|a| a == "--size") @@ -39,6 +58,7 @@ fn main() { app, ); harness.input_mut().max_texture_side = Some(8192); + harness.input_mut().system_theme = appearance; READY.store(true, Ordering::Relaxed); let step = |h: &mut egui_kittest::Harness| { let mut raw = std::mem::take(h.input_mut()); @@ -63,6 +83,15 @@ fn main() { eprintln!("bad line: {line}"); continue; }; + if let Some(value) = v.get("system_theme") { + let Some(appearance) = value.as_str().and_then(system_theme) else { + eprintln!("system_theme requires light, dark or none: {line}"); + continue; + }; + harness.input_mut().system_theme = appearance; + step(&mut harness); + continue; + } if let Some(p) = v.get("shot").and_then(|x| x.as_str()) { for _ in 0..3 { step(&mut harness); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs new file mode 100644 index 0000000..f2668fa --- /dev/null +++ b/crates/ui-egui/src/docking.rs @@ -0,0 +1,475 @@ +//! Application panel identities and persistence around the shared docking renderer. +//! Document contents remain owned by their existing view implementations. + +use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::layout::{SplitAxis, SplitSize}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Panel { + Canvas, + Tracks, + Clips, + Midi, + Memory, + History, +} + +impl Panel { + pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History]; + pub fn id(self) -> &'static str { + match self { + Self::Canvas => "canvas", + Self::Tracks => "tracks", + Self::Clips => "clips", + Self::Midi => "midi", + Self::Memory => "memory", + Self::History => "history", + } + } + fn label(self) -> &'static str { + match self { + Self::Canvas => "Session", + Self::Tracks => "Tracks and Groups", + Self::Clips => "Clips", + Self::Midi => "MIDI Editor", + Self::Memory => "Memory Locations", + Self::History => "Undo History", + } + } + fn default_side(self) -> Zone { + match self { + Self::Canvas => Zone::Right, + Self::Tracks => Zone::Left, + Self::Clips => Zone::Right, + Self::Midi => Zone::Bottom, + Self::Memory => Zone::Bottom, + Self::History => Zone::Right, + } + } + fn parse(id: &str) -> Result { + Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) + } +} + +const MIDI_TAB_HEIGHT: f32 = 26.0; + +fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +} + +fn tabs(panel: Panel) -> Node { + Node::Tabs { panels: vec![panel], active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct Workspace { + pub layout: Layout, + /// Closed panels retain their most recent group, side, size or floating rectangle. + pub hidden: Vec<(Panel, Location)>, +} + +impl Default for Workspace { + fn default() -> Self { + Self { + layout: Layout { + root: Some(split( + SplitAxis::Vertical, + SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT), + split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(168.0), + tabs(Panel::Tracks), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)), + ), + tabs(Panel::Midi), + )), + floating: vec![ + craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] }, + craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] }, + ], + }, + hidden: Vec::new(), + } + } +} + +impl Workspace { + pub fn validate(&self) -> Result<(), String> { + self.layout.validate().map_err(|error| error.to_string())?; + if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { + return Err("The panel layout must contain its document view and bounded hidden panels".into()); + } + // Saved settings cannot turn the protected document view into a floating or closable tab. + if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + let mut nodes: Vec<_> = self.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + _ => {} + } + } + let mut seen = std::collections::HashSet::new(); + for (panel, location) in &self.hidden { + if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { + return Err("A hidden panel has an invalid or duplicate identity".into()); + } + if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + let mut probe = self.layout.clone(); + if let Err(error) = probe.restore(*panel, location) + && error != craft_ui::docking::DockError::MissingTarget + { + return Err(error.to_string()); + } + } + Ok(()) + } + + pub fn validate_mix(&self) -> Result<(), String> { + self.validate()?; + if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + Ok(()) + } + + pub fn apply(&mut self, action: Action) -> Result<(), String> { + self.validate()?; + let mut next = self.clone(); + if let Action::Close { panel } = &action { + let location = next.layout.location(panel).map_err(|error| error.to_string())?; + next.hidden.retain(|(id, _)| id != panel); + next.hidden.push((*panel, location)); + } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { + next.hidden.retain(|(id, _)| id != panel); + } + next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; + next.validate()?; + *self = next; + Ok(()) + } + + pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { + if self.layout.contains(&panel) == visible { + return Ok(()); + } + if !visible { + return self.apply(Action::Close { panel }); + } + self.validate()?; + let mut next = self.clone(); + let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + next.hidden.retain(|(id, _)| *id != panel); + if let Some(saved) = &mut location + && saved.floating.is_some() + && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) + { + // The other members of a floating group may have been closed too. + saved.anchor = None; + saved.placement = Placement::Tab { before: None }; + } + let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); + if !restored { + next.layout + .apply_with_permissions( + Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, + permissions, + ) + .map_err(|error| error.to_string())?; + } + next.validate()?; + *self = next; + Ok(()) + } + + fn command(&mut self, params: &Value) -> Result<(), String> { + self.validate()?; + if let Some(action) = params.get("action") { + let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; + return self.apply(action); + } + let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; + if operation == "resizeSplit" { + let path: Vec = + serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; + let size: SplitSize = + serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; + return self.apply(Action::ResizeSplit { path, size }); + } + let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; + let action = match operation { + "open" => return self.set_visible(panel, true), + "close" => Action::Close { panel }, + "activate" => Action::Activate { panel }, + "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, + "resizeStack" => { + let value = params.get("height").ok_or("height is required")?; + let height = if value.is_null() { + None + } else { + Some( + value + .as_f64() + .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) + .ok_or("height must be finite and bounded")? as f32, + ) + }; + Action::ResizeStack { panel, height } + } + "float" | "moveFloating" => { + let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; + let mut values = [0.0f32; 4]; + for (output, value) in values.iter_mut().zip(rect) { + *output = value + .as_f64() + .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) + .ok_or("rect values must be finite and bounded")? as f32; + } + if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } + } + "move" => { + let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; + let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { + "center" => Placement::Split(Zone::Center), + "left" => Placement::Split(Zone::Left), + "right" => Placement::Split(Zone::Right), + "top" => Placement::Split(Zone::Top), + "bottom" => Placement::Split(Zone::Bottom), + _ => return Err("zone must be center, left, right, top or bottom".into()), + }; + let placement = if let Some(before) = params.get("before") { + if !matches!(placement, Placement::Split(Zone::Center)) { + return Err("before only applies to a center move".into()); + } + Placement::Tab { + before: if before.is_null() { + None + } else { + Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) + }, + } + } else { + placement + }; + Action::Move { panel, anchor, placement } + } + _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), + }; + self.apply(action) + } +} + +/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit. +pub fn mix_default() -> Workspace { + let mut workspace = Workspace::default(); + let _ = workspace.set_visible(Panel::Clips, false); + let _ = workspace.set_visible(Panel::Midi, false); + workspace +} + +fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> { + workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?; + workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?; + workspace.set_visible(Panel::History, app.ui.show_undo_history)?; + if edit { + workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?; + workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?; + } + Ok(()) +} + +fn publish_visibility(app: &mut crate::SoundApp, workspace: &Workspace, edit: bool) { + app.ui.show_tracks_list = workspace.layout.contains(&Panel::Tracks); + app.ui.show_memory_locations = workspace.layout.contains(&Panel::Memory); + app.ui.show_undo_history = workspace.layout.contains(&Panel::History); + if edit { + app.ui.show_clip_list = workspace.layout.contains(&Panel::Clips); + app.ui.show_midi_editor = workspace.layout.contains(&Panel::Midi); + } +} + +fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) { + if edit { + app.ui.docking = workspace; + } else { + app.ui.mix_docking = workspace; + } +} + +pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result { + let edit = app.ui.window == crate::MainWindow::Edit; + let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + let result = command_for(app, reset, params, edit)?; + let next = if edit { &app.ui.docking } else { &app.ui.mix_docking }; + cancel_replaced_workspace(app, &previous, next, edit); + Ok(result) +} + +fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) { + if (previous.layout != next.layout || previous.hidden != next.hidden) + && let Some(ctx) = &app.docking_context + { + craft_ui::docking::cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit))); + } +} + +/// Restoring complete preferences/configurations uses the same ownership boundary as commands. +pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState) { + cancel_replaced_workspace(app, &app.ui.docking, &next.docking, true); + cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false); +} + +fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result { + if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + if !reset && !edit { + workspace.validate_mix()?; + } + if reset { + workspace = if edit { Workspace::default() } else { mix_default() }; + sync(&mut workspace, app, edit)?; + } else { + sync(&mut workspace, app, edit)?; + workspace.command(params)?; + if !edit { + workspace.validate_mix()?; + } + } + publish_visibility(app, &workspace, edit); + let result = json!({"docking": workspace}); + store(app, workspace, edit); + Ok(result) +} + +/// Existing Arrange commands reposition shared floating panels along with the legacy windows. +pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> { + let edit = app.ui.window == crate::MainWindow::Edit; + let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + if edit { + workspace.validate()?; + } else { + workspace.validate_mix()?; + } + let previous = workspace.clone(); + let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect(); + for (index, (panel, mut rect)) in floating.into_iter().enumerate() { + rect[0] = 32.0 + index as f32 * 28.0; + rect[1] = 80.0 + index as f32 * 28.0; + workspace.apply(Action::MoveFloating { panel, rect })?; + } + cancel_replaced_workspace(app, &previous, &workspace, edit); + store(app, workspace, edit); + Ok(()) +} + +pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) { + app.docking_context = Some(ui.ctx().clone()); + let t = crate::theme::Tokens::current(); + let edit = window == crate::MainWindow::Edit; + let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) }; + let before_sync = workspace.clone(); + if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) { + app.ui.status = format!("Panel layout could not be restored: {error}"); + workspace = if edit { Workspace::default() } else { mix_default() }; + let _ = sync(&mut workspace, app, edit); + } + cancel_replaced_workspace(app, &before_sync, &workspace, edit); + let mut style = DockStyle::from_ui(ui); + style.min_pane = 96.0; + style.tab_height = MIDI_TAB_HEIGHT; + style.background = t.panel_bg; + style.tab_background = t.panel_bg2; + style.active_background = t.row_selected; + style.text = t.text; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.font = crate::theme::regular(11.5); + let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits( + ui, + &workspace.layout, + &style, + |panel| panel.label().into(), + permissions, + panel_limits, + |ui, panel| match panel { + Panel::Canvas => { + ui.painter().rect_filled(ui.max_rect(), 0.0, t.window_bg); + if edit { + crate::edit_window::main_area(app, ui); + } else { + crate::mix_window::content(app, ui); + } + } + Panel::Tracks => crate::panels::tracks_and_groups(app, ui), + Panel::Clips => crate::panels::clip_list(app, ui), + Panel::Midi => { + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect())); + minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui)); + } + Panel::Memory => crate::panels::memory_locations(app, ui), + Panel::History => crate::panels::undo_history(app, ui), + }, + ); + let _ = sync(&mut workspace, app, edit); + publish_visibility(app, &workspace, edit); + store(app, workspace, edit); + for action in output.actions { + if let Err(error) = serde_json::to_value(action) + .map_err(|error| error.to_string()) + .and_then(|action| command_for(app, false, &json!({"action": action}), edit)) + { + app.ui.status = format!("Panel move was not applied: {error}"); + } + } +} + +/// Docking may make a specialist view smaller than its fixed tool/ruler geometry. Keep that +/// geometry scrollable inside its clipped panel instead of producing inverted rectangles. +pub(crate) fn minimum_body(ui: &mut egui::Ui, minimum: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) { + let available = ui.available_size(); + if available.x >= minimum.x && available.y >= minimum.y { + body(ui); + return; + } + let extent = available.max(minimum); + egui::ScrollArea::both().id_salt("minimum-body").auto_shrink([false, false]).show(ui, |ui| { + let (rect, _) = ui.allocate_exact_size(extent, egui::Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + body(&mut child); + }); +} + +fn panel_limits(panel: &Panel) -> PanelLimits { + let (min, max_width) = match panel { + Panel::Canvas => (egui::vec2(300.0, 240.0), 1_000_000.0), + Panel::Tracks => (egui::vec2(140.0, 120.0), 520.0), + Panel::Clips => (egui::vec2(180.0, 120.0), 720.0), + Panel::Midi => (egui::vec2(420.0, 260.0), 1_000_000.0), + Panel::Memory => (egui::vec2(260.0, 160.0), 1_000_000.0), + Panel::History => (egui::vec2(200.0, 160.0), 720.0), + }; + PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..906ae1e 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -2,7 +2,7 @@ use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; use crate::widgets::{rec_toggle, selector_box, text_toggle}; -use crate::{Gesture, SoundApp, icons, panels, toolbar}; +use crate::{Gesture, SoundApp, icons, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; @@ -66,27 +66,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); - if app.ui.show_midi_editor { - egui::Panel::bottom("midi_editor") - .resizable(true) - .default_size(320.0) - .min_size(260.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| crate::midi_editor::show(app, ui)); - } if app.ui.show_universe { egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Tokens::current().universe_bg)).show(ui, |ui| universe(app, ui)); } - if app.ui.show_tracks_list { - egui::Panel::left("tracks_list") - .exact_size(168.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| panels::tracks_and_groups(app, ui)); - } - if app.ui.show_clip_list { - egui::Panel::right("clip_list").exact_size(230.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| panels::clip_list(app, ui)); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui)); + crate::docking::show(app, ui, crate::MainWindow::Edit); } /// Universe: the whole session in miniature; the box is the visible area (drag or click to move). @@ -177,7 +160,17 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { ); } -fn main_area(app: &mut SoundApp, ui: &mut Ui) { +pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) { + let session = app.engine.session(); + let mut ruler_height_total = session.edit.rulers.iter().map(|ruler| ruler_height(ruler)).sum::(); + if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") { + ruler_height_total += ruler_height("tempo_editor"); + } + let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0); + crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui)); +} + +fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let full = ui.max_rect(); let rulers: Vec = app.engine.session().edit.rulers.clone(); diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index 71e63fa..e269573 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option { } pub fn show(app: &mut SoundApp, ctx: &egui::Context) { - apply_customization(app, ctx); auto_update(app, ctx); renderer(app, ctx); ui_customization(app, ctx); + apply_customization(app, ctx); } fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) { let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion); - if app.extra.applied == Some(want) { + // Rescaling mid-drag would move the Interface scale slider out from under the pointer. + if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) { return; } // Only touch the zoom once the user has changed it, so platform scaling stays untouched. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..b324ad3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ pub mod control; pub mod credits; pub mod dialogs; +pub mod docking; pub mod edit_window; pub mod extra_windows; pub mod fonts; @@ -47,6 +48,9 @@ pub enum MainWindow { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct UiState { + pub docking: docking::Workspace, + #[serde(default = "docking::mix_default")] + pub mix_docking: docking::Workspace, pub window: MainWindow, pub show_tracks_list: bool, pub show_clip_list: bool, @@ -102,6 +106,8 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + docking: docking::Workspace::default(), + mix_docking: docking::mix_default(), window: MainWindow::Edit, show_tracks_list: true, show_clip_list: true, @@ -214,6 +220,8 @@ pub struct SoundApp { pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, + /// Reused multichannel snapshot; populated at an adaptive rate by `logic`. + meter_snapshot: soundcraft_playback::MeterSnapshot, pub gesture: Option, pub edit_layout: edit_window::EditLayout, pub midi: midi_editor::MidiEditorState, @@ -229,6 +237,8 @@ pub struct SoundApp { fonts_installed: bool, /// The palette last applied to the context (light?), so visuals are set only on a change. theme_applied: Option, + /// Last panel host context, used to cancel renderer-owned gestures on explicit replacement. + pub(crate) docking_context: Option, /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, pub quit_requested: bool, @@ -239,6 +249,7 @@ pub struct SoundApp { last_write_at: Samples, pub frame_ms: f32, last_frame: Option, + last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, } @@ -272,6 +283,7 @@ impl SoundApp { dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), + meter_snapshot: soundcraft_playback::MeterSnapshot::default(), gesture: None, edit_layout: edit_window::EditLayout::default(), midi: midi_editor::MidiEditorState::default(), @@ -286,6 +298,7 @@ impl SoundApp { fonts_ready: false, fonts_installed: false, theme_applied: None, + docking_context: None, sim: None, quit_requested: false, arrange_request: false, @@ -293,6 +306,7 @@ impl SoundApp { last_write_at: i64::MIN, frame_ms: 0.0, last_frame: None, + last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), } } @@ -320,6 +334,9 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "track.clear_clip_indicators" { + self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); + } Ok(v) } Err(e) => { @@ -358,6 +375,8 @@ impl SoundApp { fn stop_play(&mut self) { if let Some(p) = &self.player { + // Half-speed is a separate command. Stop must not leave that rate for the next Play. + p.set_speed(1.0); p.stop(); } if self.engine.transport.recording { @@ -503,6 +522,12 @@ impl SoundApp { best.map(|(_, p)| p) } + /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard. + fn meters_live(&self) -> bool { + self.engine.transport.playing + || (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)) + } + /// Open the input device once (for recording and input monitoring) and feed the player. fn ensure_input(&mut self) { if self.recorder.is_some() || self.recorder_failed || self.player.is_none() { @@ -642,6 +667,25 @@ impl SoundApp { let reqs: Vec = std::mem::take(&mut self.engine.transport_requests); for r in reqs { match r { + TransportRequest::ResetSession => { + if let Some(p) = &self.player { + p.stop(); + p.set_recording(false); + p.set_speed(1.0); + p.locate(self.engine.transport.position); + } + self.sim = None; + // The old capture belongs to the replaced document, never to the new one. + self.recorder = None; + self.recorder_failed = false; + self.punch = None; + self.gesture = None; + self.touched.clear(); + self.last_write_at = i64::MIN; + self.edit_layout.follow_hold = false; + self.meters.clear(); + self.main_meter = MeterDisplay::default(); + } TransportRequest::Play => { if !self.is_playing() { self.play_from_selection(); @@ -697,7 +741,13 @@ impl SoundApp { if self.engine.transport.playing { self.engine.transport.position = p.position(); } - let snap = p.meters(); + let now = self.last_frame.unwrap_or_default(); + let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 }; + if now - self.last_meter_poll >= meter_interval { + p.copy_meters(&mut self.meter_snapshot); + self.last_meter_poll = now; + } + let snap = &self.meter_snapshot; for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default(); feed_meter(d, m.peak, m.gain_reduction_db, dt); @@ -721,7 +771,7 @@ impl SoundApp { self.stop_play(); } } - if !self.engine.transport.playing { + if !self.meters_live() { for d in self.meters.values_mut() { feed_meter(d, [0.0, 0.0], 0.0, dt); } @@ -761,13 +811,14 @@ impl SoundApp { self.engine.end_merge(); } } + // Stop the old playback before sending a replacement session to the audio thread. + self.handle_transport(dt); if self.engine.revision != self.last_rev { self.last_rev = self.engine.revision; if let Some(p) = &self.player { p.update_session(self.engine.session_arc()); } } - self.handle_transport(dt); self.write_automation(ctx); self.autosave(now); if self.recorder.is_none() @@ -776,7 +827,12 @@ impl SoundApp { { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + if self.meters_live() { + let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 }; + ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz)); + } else if self.meters.values().any(|m| m.level[0] > 0.0001) { + ctx.request_repaint_after(std::time::Duration::from_millis(80)); + } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() { ctx.request_repaint(); } control::collect_screenshots(self, ctx); @@ -818,6 +874,8 @@ impl SoundApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // The integration creates this Ui before logic resolves the current frame's palette. + ui.set_style(ctx.global_style()); if !self.fonts_ready { ctx.request_repaint(); return; @@ -889,7 +947,74 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { #[cfg(test)] mod tests { - use super::automation_step_due; + use super::{Services, SoundApp, automation_step_due}; + use serde_json::json; + + #[test] + fn new_session_stops_playback_and_the_old_clock() { + for (command, params) in [ + ("session.new", json!({"name": "New project", "sample_rate": 96_000})), + ("session.new", json!({"template": "demo"})), + ("session.close", json!({})), + ] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert!(app.position() > 48_000); + app.run(command, params).unwrap(); + let insertion = app.engine.session().edit.selection.start; + app.handle_transport(0.25); + assert!(!app.is_playing(), "{command}"); + assert_eq!(app.position(), insertion, "{command}"); + assert_eq!(app.engine.transport.position, insertion, "{command}"); + app.handle_transport(0.25); + assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped"); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4); + } + } + + #[test] + fn new_session_cancels_play_queued_before_the_next_frame() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({})).unwrap(); + app.run("session.new", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.transport.position, 0); + } + + #[test] + fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + let mut session = soundcraft_model::Session::default(); + session.edit.selection = soundcraft_time::Range::new(12_000, 24_000); + session.edit.insertion_follows_playback = true; + session.edit.scrolling = "after_playback".into(); + let selection = session.edit.selection; + app.engine.replace_session(session); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.session().edit.selection, selection); + assert_eq!(app.position(), selection.start); + assert_eq!(app.engine.session().edit.zoom.scroll, 0); + assert!(!app.engine.is_dirty()); + } + + #[test] + fn clear_all_clip_indicators_clears_every_meter() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.meters.entry(kick).or_default().clip = true; + app.main_meter.clip = true; + app.run("track.clear_clip_indicators", json!({})).unwrap(); + assert!(!app.meters[&kick].clip && !app.main_meter.clip); + } #[test] fn automation_first_pass_after_start_is_due_without_overflow() { @@ -909,4 +1034,43 @@ mod tests { fn automation_write_fires_after_loop_wrap() { assert!(automation_step_due(0, 1_000_000, 960)); } + + #[test] + fn ordinary_play_after_stop_is_full_speed() { + use serde_json::json; + let engine = soundcraft_engine::Engine::default(); + let player = soundcraft_playback::Player::new(engine.session_arc()); + let mut app = super::SoundApp::new(engine, Some(player), super::Services::default()); + app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap(); + app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap(); + app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap(); + let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0); + assert!((speed(&app) - 1.0).abs() < 1e-3); + + app.engine.execute("transport.half_speed", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(app.is_playing()); + assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback"); + + app.engine.execute("transport.stop", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(!app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed"); + + app.engine.execute("transport.play", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed"); + + app.engine.execute("transport.stop", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap(); + app.engine.execute("transport.half_speed", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!((speed(&app) - 0.5).abs() < 1e-3); + app.player.as_ref().unwrap().stop(); + app.handle_transport(1.0 / 60.0); + assert!(!app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed"); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f1b9a4..258ee9b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,8 @@ use soundcraft_engine::catalog; /// UI-layer commands: (id, label, catalog path or "", shortcut). pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ + ("ui.dock", "Arrange Panel", "", None), + ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None), ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")), ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")), @@ -74,29 +76,41 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ /// Extra catalog mappings handled by the UI layer. pub fn ui_aliases() -> Vec<(&'static str, &'static str)> { let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect(); - for sec in [ - "Inserts A-E", - "Inserts F-J", - "Sends A-E", - "Sends F-J", - "I/O", - "Track Color", - "Comments", - "EQ Curve", - "Meters and Faders", - "All", - "Minimal", - "Mic Preamps", - "Instruments", - "Object", - ] { - v.push((Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()), "view.mix_section")); - } + v.extend(mix_view_aliases().iter().copied()); v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process"))); v.extend(MENU_WINDOWS.iter().copied()); v } +/// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame. +fn mix_view_aliases() -> &'static [(&'static str, &'static str)] { + static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new(); + ALIASES.get_or_init(|| { + [ + "Inserts A-E", + "Inserts F-J", + "Sends A-E", + "Sends F-J", + "I/O", + "Track Color", + "Comments", + "EQ Curve", + "Meters and Faders", + "All", + "Minimal", + "Mic Preamps", + "Instruments", + "Object", + ] + .into_iter() + .map(|sec| { + let path: &'static str = Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()); + (path, "view.mix_section") + }) + .collect() + }) +} + /// AudioSuite catalog entries → our processes (functional equivalents, our own plugins). pub const AUDIOSUITE: &[(&str, &str)] = &[ ("AudioSuite > EQ > Channel Strip", "channel_strip"), @@ -183,6 +197,7 @@ pub fn tree() -> Vec { for line in catalog::catalog() { insert_path(&mut roots, line); } + insert_path(&mut roots, "Window > Reset Panel Layout"); for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } @@ -266,7 +281,11 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option { /// Enabled and checked state shared by all menu presentations. pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { let enabled = match id { - Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true, + Some(i) + if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") => + { + true + } Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), None => false, }; @@ -391,6 +410,15 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { if wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) { return; } + // These ids only exist to open a dialog from the menu. Programmatic `app.run` refuses them. + if id == "ui.new_tracks_dialog" { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + return; + } + if id == "ui.bounce_dialog" { + let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix"); + return; + } let params = params_for(path, id); let _ = app.run(id, params); } @@ -407,6 +435,9 @@ fn wants_dialog(id: &str) -> bool { /// Handle UI-layer commands. Returns None when `id` is not a UI command. pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } if let Some(v) = crate::extra_windows::run(app, id, p) { return Some(Ok(v)); } @@ -474,6 +505,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_configurations), "window.config_new" | "window.config_update" => { + if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) { + return Some(Err(error)); + } let mut snap = app.ui.clone(); snap.configurations.clear(); let v = serde_json::to_value(&snap).unwrap_or_default(); @@ -488,6 +522,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { + if let Err(error) = crate::docking::arrange(app) { + app.ui.status = format!("Panel windows could not be arranged: {error}"); + } app.arrange_request = true; json!({}) } @@ -525,12 +562,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { - let _ = app.dialogs.open_for_command(&app.engine, "track.new"); - json!({}) + return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into())); } "ui.bounce_dialog" => { - let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix"); - json!({}) + return Some(Err("ui.bounce_dialog opens a dialog from the menu only; call file.bounce_mix with a path".into())); } "ui.theme" => { let Some(mode) = p.get("mode").and_then(Value::as_str) else { @@ -539,9 +574,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option Option(cur) { Ok(ns) => { + if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) { + return Some(Err(error)); + } + crate::docking::cancel_replaced_state(app, &ns); app.ui = ns; json!({"ui": app.ui}) } @@ -572,3 +608,35 @@ pub fn parity() -> Value { let extra = ui_aliases(); catalog::parity_with(&extra) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Services, SoundApp}; + + #[test] + fn mix_view_aliases_are_allocated_once() { + let first = ui_aliases(); + let second = ui_aliases(); + let a = first.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap(); + let b = second.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap(); + assert!(std::ptr::eq(a, b)); + assert_eq!(first.iter().filter(|(path, _)| path.contains("Mix Window Views")).count(), 14); + } + + #[test] + fn programmatic_dialog_commands_stay_closed_and_the_menu_opens_them() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let err = app.run("ui.new_tracks_dialog", json!({})).unwrap_err(); + assert!(err.contains("track.new"), "{err}"); + let err = app.run("ui.bounce_dialog", json!({})).unwrap_err(); + assert!(err.contains("file.bounce_mix"), "{err}"); + assert!(app.dialogs.open_name().is_none()); + + invoke_menu(&mut app, "ui.new_tracks_dialog", "Track > New..."); + assert_eq!(app.dialogs.open_name(), Some("new_tracks")); + app.dialogs.open = None; + invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix..."); + assert_eq!(app.dialogs.open_name(), Some("bounce")); + } +} diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 3716c98..a4882d4 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -56,6 +56,9 @@ fn is_black(p: i32) -> bool { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { + // Painter-only contents do not advance the layout. Claim the resized + // space, including the empty state, so the dock keeps its chosen height. + ui.take_available_space(); let t = Tokens::current(); let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.panel_bg); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..d977825 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -1,10 +1,11 @@ //! The Mix window: one channel strip per track. +use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, surround_panner, text_toggle, + PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip, + surround_panner, text_toggle, }; -use crate::{SoundApp, panels}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{ChannelFormat, Route, SurroundPan, Track, TrackId, TrackKind}; @@ -15,28 +16,19 @@ pub fn strip_width(narrow: bool) -> f32 { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::current(); - if app.ui.show_tracks_list { - egui::Panel::left("mix_tracks_list") - .exact_size(168.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| panels::tracks_and_groups(app, ui)); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| { - let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); - // Per-channel peaks for multichannel meters (read once per frame). - let snap = app - .player - .as_ref() - .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) - .map(|p| p.meters()); - egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { - ui.horizontal_top(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - for id in ids { - strip(app, ui, id, snap.as_ref()); - } - }); + crate::docking::show(app, ui, crate::MainWindow::Mix); +} + +pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { + let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); + // Per-channel peaks for multichannel meters (read once per frame). + let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters()); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + for id in ids { + strip(app, ui, id, snap.as_ref()); + } }); }); } @@ -247,7 +239,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } else { t.auto_write }; - let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col); + let full_label = format!("auto {}", mode.label()); + let label = if mode == soundcraft_model::AutomationMode::TouchLatch { + if narrow { "t/l" } else { mode.label() } + } else if narrow { + mode.label() + } else { + &full_label + }; + let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label); egui::Popup::menu(&resp).show(|ui| { for m in soundcraft_model::AutomationMode::ALL { if ui.selectable_label(m == mode, m.label()).clicked() { @@ -410,12 +410,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let ins = track.instrument.as_ref(); let info = ins.and_then(|i| plugin_info(&i.plugin)); let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click()); - let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg }; + let fill = if info.is_some() { t.insert_on } else { t.slot_bg }; ui.painter().rect( r, CornerRadius::same(2), if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }, - Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), + Stroke::new(1.0, t.slot_border), StrokeKind::Inside, ); ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text); diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index d298ab3..87c7dde 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -123,6 +123,21 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_beat_detective = open; } +/// Command the Tempo Operations Apply button sends. Stretch uses `factor = start / end` +/// so a lower end BPM lengthens the selection (`event.tempo_stretch` has no `end_bpm`). +pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'static str, Value) { + let start = if start_bpm.is_finite() { start_bpm.max(1.0) } else { 1.0 }; + let end = if end_bpm.is_finite() { end_bpm.max(1.0) } else { 1.0 }; + match curve { + "constant" => ("event.tempo_constant", json!({"bpm": start_bpm})), + "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + "scale" => ("event.tempo_scale", json!({"factor": end / start})), + "stretch" => ("event.tempo_stretch", json!({"factor": start / end})), + _ => ("event.tempo_linear", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + } +} + fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_tempo_ops; if !open { @@ -136,16 +151,14 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { }); ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm ")); ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm ")); - ui.label(egui::RichText::new("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start).").small()); + ui.label( + egui::RichText::new( + "Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).", + ) + .small(), + ); if ui.button(egui::RichText::new("Apply").strong()).clicked() { - let (id, p) = match app.ops.tempo_curve.as_str() { - "constant" => ("event.tempo_constant", json!({"bpm": app.ops.tempo_start})), - "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - "scale" => ("event.tempo_scale", json!({"factor": app.ops.tempo_end / app.ops.tempo_start.max(1.0)})), - "stretch" => ("event.tempo_stretch", json!({"end_bpm": app.ops.tempo_end})), - _ => ("event.tempo_linear", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - }; + let (id, p) = tempo_ops_apply(&app.ops.tempo_curve, app.ops.tempo_start, app.ops.tempo_end); let r = app.run(id, p); result_line(ui, &r); } @@ -274,3 +287,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) { }); app.ui.show_rtp = open; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stretch_uses_start_over_end_and_scale_uses_end_over_start() { + let (id, p) = tempo_ops_apply("stretch", 120.0, 60.0); + assert_eq!(id, "event.tempo_stretch"); + assert!((p["factor"].as_f64().unwrap() - 2.0).abs() < 1e-9, "{p}"); + let (_, p) = tempo_ops_apply("stretch", 120.0, 120.0); + assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}"); + let (id, p) = tempo_ops_apply("scale", 120.0, 60.0); + assert_eq!(id, "event.tempo_scale"); + assert!((p["factor"].as_f64().unwrap() - 0.5).abs() < 1e-9, "{p}"); + let (_, p) = tempo_ops_apply("stretch", f64::NAN, f64::INFINITY); + assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}"); + let (id, p) = tempo_ops_apply("linear", 100.0, 140.0); + assert_eq!(id, "event.tempo_linear"); + assert_eq!(p["end_bpm"], 140.0); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 24eadb7..153c983 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -173,9 +173,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { /// Floating windows. pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); - memory_locations(app, ctx); big_counter(app, ctx); - undo_history(app, ctx); plugin_windows(app, ctx); about(app, ctx); session_info(app, ctx); @@ -282,92 +280,78 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_big_counter = open; } -fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) { - let mut open = app.ui.show_memory_locations; - if !open { - return; - } - egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(480.0, 280.0)).show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("+ Marker").clicked() { - let _ = app.run("markers.add", json!({})); +pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { + ui.horizontal(|ui| { + if ui.button("+ Marker").clicked() { + let _ = app.run("markers.add", json!({})); + } + if ui.button("+ Selection").clicked() { + let _ = app.run("markers.add", json!({"kind": "selection"})); + } + }); + let rows: Vec<(u32, String, String, String, String)> = { + let s = app.engine.session(); + let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + s.markers + .iter() + .map(|m| { + let kind = match m.kind { + soundcraft_model::MarkerKind::Marker => "marker", + soundcraft_model::MarkerKind::Selection => "selection", + soundcraft_model::MarkerKind::None => "none", + }; + let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() }; + (m.number, m.name.clone(), f(m.start), len, kind.to_string()) + }) + .collect() + }; + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| { + for h in ["#", "Name", "Location", "Length", "Type", ""] { + ui.label(egui::RichText::new(h).strong()); } - if ui.button("+ Selection").clicked() { - let _ = app.run("markers.add", json!({"kind": "selection"})); - } - }); - let rows: Vec<(u32, String, String, String, String)> = { - let s = app.engine.session(); - let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); - s.markers - .iter() - .map(|m| { - let kind = match m.kind { - soundcraft_model::MarkerKind::Marker => "marker", - soundcraft_model::MarkerKind::Selection => "selection", - soundcraft_model::MarkerKind::None => "none", - }; - let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() }; - (m.number, m.name.clone(), f(m.start), len, kind.to_string()) - }) - .collect() - }; - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| { - for h in ["#", "Name", "Location", "Length", "Type", ""] { - ui.label(egui::RichText::new(h).strong()); + ui.end_row(); + for (n, name, loc, len, kind) in rows { + if ui.button(n.to_string()).on_hover_text("Recall").clicked() { + let _ = app.run("markers.recall", json!({"number": n})); + } + let key = egui::Id::new(("mem_name", n)); + let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); + if r.changed() { + ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); + } + if r.lost_focus() && edit != name { + let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); + ui.ctx().memory_mut(|m| m.data.remove::(key)); + } + ui.label(egui::RichText::new(loc).font(mono(11.0))); + ui.label(egui::RichText::new(len).font(mono(11.0))); + ui.label(kind); + if ui.small_button("✕").on_hover_text("Delete").clicked() { + let _ = app.run("markers.delete", json!({"number": n})); } ui.end_row(); - for (n, name, loc, len, kind) in rows { - if ui.button(n.to_string()).on_hover_text("Recall").clicked() { - let _ = app.run("markers.recall", json!({"number": n})); - } - let key = egui::Id::new(("mem_name", n)); - let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); - if r.changed() { - ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); - } - if r.lost_focus() && edit != name { - let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); - ui.ctx().memory_mut(|m| m.data.remove::(key)); - } - ui.label(egui::RichText::new(loc).font(mono(11.0))); - ui.label(egui::RichText::new(len).font(mono(11.0))); - ui.label(kind); - if ui.small_button("✕").on_hover_text("Delete").clicked() { - let _ = app.run("markers.delete", json!({"number": n})); - } - ui.end_row(); - } - }); + } }); }); - app.ui.show_memory_locations = open; } -fn undo_history(app: &mut SoundApp, ctx: &egui::Context) { - let mut open = app.ui.show_undo_history; - if !open { - return; - } - egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| { - let hist = app.engine.undo_history(); - let n = hist.len(); - egui::ScrollArea::vertical().show(ui, |ui| { - for (i, h) in hist.iter().enumerate() { - if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { - for _ in 0..(n - i - 1) { - let _ = app.run("edit.undo", json!({})); - } +pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { + let hist = app.engine.undo_history(); + let n = hist.len(); + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, h) in hist.iter().enumerate() { + if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { + for _ in 0..(n - i - 1) { + let _ = app.run("edit.undo", json!({})); } } - if let Some(r) = app.engine.redo_label() { - ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); - } - }); + } + if let Some(r) = app.engine.redo_label() { + ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); + } }); - app.ui.show_undo_history = open; } fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bffa713..fb978c3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool { } pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { + // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom. + ctx.options_mut(|o| o.zoom_with_keyboard = false); if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() { return; } @@ -170,6 +172,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run(id, json!({})); true } + // Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key). + Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => { + let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" }; + let _ = app.run(id, json!({})); + true + } // Commands Keyboard Focus: single-key editing (Edit window only). k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true, Key::R | Key::T if plain => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 113e152..c667e6f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,17 +1,17 @@ //! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours. use egui::{Color32, FontFamily, FontId}; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; /// Whether the Light palette is active. Dark is the default (the incumbent is dark-only). static LIGHT_THEME: AtomicBool = AtomicBool::new(false); -/// The operating system's appearance, asked once off the UI thread. -static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN); -const SYSTEM_UNKNOWN: u8 = 0; -const SYSTEM_ASKING: u8 = 1; -const SYSTEM_DARK: u8 = 2; -const SYSTEM_LIGHT: u8 = 3; +/// Unit tests that change the process-wide palette must own it until their assertions finish. +#[cfg(test)] +pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> { + static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} /// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -335,49 +335,15 @@ pub fn is_light() -> bool { LIGHT_THEME.load(Ordering::Relaxed) } -/// Whether `mode` resolves to the Light palette right now. Never blocks. +/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark. pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { match mode { ThemeMode::Light => true, ThemeMode::Dark => false, - ThemeMode::System => system_is_light(ctx), + ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light), } } -/// Forget the cached system appearance so the next System lookup asks again. -pub fn refresh_system() { - let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); - let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); -} - -/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it -/// answers this reports dark, and `ctx` is repainted when the answer arrives. -#[cfg(not(target_arch = "wasm32"))] -fn system_is_light(ctx: &egui::Context) -> bool { - if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - let ctx = ctx.clone(); - let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || { - let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); - SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); - ctx.request_repaint(); - }); - if spawned.is_err() { - SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed); - } - } - SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT -} - -/// On the web the query is a synchronous `matchMedia` lookup (no threads there). -#[cfg(target_arch = "wasm32")] -fn system_is_light(_ctx: &egui::Context) -> bool { - if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); - SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); - } - SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT -} - /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; @@ -430,8 +396,13 @@ pub fn apply(ctx: &egui::Context, light: bool) { v.widgets.inactive.fg_stroke.color = t.text; v.window_corner_radius = egui::CornerRadius::same(6); v.menu_corner_radius = egui::CornerRadius::same(4); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // Keep receiving native OS appearance changes even when our palette is manual. On macOS, + // pinning egui's theme also pins the window appearance and hides later OS changes. + ctx.set_theme(egui::ThemePreference::System); + // Both egui style branches must use our resolved palette: egui can select either branch + // as the OS changes, including while SoundCraft's manual Light/Dark choice stays fixed. + ctx.all_styles_mut(|s| { + s.visuals = v.clone(); s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 2.0); s.interaction.tooltip_delay = 0.4; @@ -476,4 +447,79 @@ mod tests { assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"}))); assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme")); } + + #[test] + fn application_frames_follow_system_appearance_and_preserve_manual_palettes() { + // Mixer rendering tests also change the process-wide custom-painted palette. + let _palette = lock_test_palette(); + fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option, light: bool) { + let mode = app.ui.theme; + let colors = |app: &SoundApp| { + app.engine + .session() + .tracks + .iter() + .map(|track| { + (track.color, track.playlists.iter().flat_map(|playlist| &playlist.clips).map(|clip| clip.color).collect::>()) + }) + .collect::>() + }; + let before = colors(app); + let mut output = ctx.run_ui(egui::RawInput { system_theme, ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + let tokens = if light { Tokens::LIGHT } else { Tokens::DARK }; + assert_eq!(is_light(), light, "custom palette for {mode:?} with {system_theme:?}"); + assert_eq!(Tokens::current().panel_bg, tokens.panel_bg); + assert_eq!(ctx.options(|options| options.theme_preference), egui::ThemePreference::System); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + let style = ctx.style_of(theme); + assert_eq!(style.visuals.dark_mode, !light); + assert_eq!(style.visuals.panel_fill, tokens.panel_bg); + assert_eq!(style.visuals.widgets.inactive.bg_fill, tokens.button); + assert_eq!(style.visuals.widgets.inactive.fg_stroke.color, tokens.text); + assert_eq!(style.spacing.button_padding, egui::vec2(6.0, 2.0)); + } + app.ui(ui); + assert_eq!(ui.visuals().panel_fill, tokens.panel_bg); + assert_eq!(ui.visuals().widgets.inactive.bg_fill, tokens.button); + }); + output.textures_delta.clear(); + assert_eq!(app.ui.theme, mode); + assert_eq!(serde_json::to_value(&app.ui).unwrap()["theme"], mode.id()); + assert_eq!(colors(app), before, "appearance must preserve track and clip colors"); + } + + let appearances = [Some(egui::Theme::Light), Some(egui::Theme::Dark), Some(egui::Theme::Light), None]; + for initial in [Some(egui::Theme::Light), Some(egui::Theme::Dark), None] { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.ui = serde_json::from_value(json!({"theme": "system"})).unwrap(); + frame(&mut app, &ctx, initial, initial == Some(egui::Theme::Light)); + } + + let ctx = egui::Context::default(); + let mut engine = soundcraft_engine::demo::demo_engine(); + let track = engine.session_mut().tracks.first_mut().unwrap(); + track.color = [19, 83, 147]; + track.playlists.iter_mut().flat_map(|playlist| &mut playlist.clips).next().unwrap().color = Some([211, 57, 99]); + let mut app = SoundApp::new(engine, None, Services::default()); + for mode in [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] { + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + for appearance in appearances { + let light = mode == ThemeMode::Light || (mode == ThemeMode::System && appearance == Some(egui::Theme::Light)); + frame(&mut app, &ctx, appearance, light); + } + if mode != ThemeMode::System { + let latest = if mode == ThemeMode::Light { egui::Theme::Dark } else { egui::Theme::Light }; + frame(&mut app, &ctx, Some(latest), mode == ThemeMode::Light); + app.run("ui.theme", json!({"mode": "system"})).unwrap(); + // Resolve the current context immediately, without another OS appearance event. + app.logic(&ctx); + assert_eq!(is_light(), latest == egui::Theme::Light); + assert_eq!(ctx.global_style().visuals.dark_mode, latest != egui::Theme::Light); + frame(&mut app, &ctx, Some(latest), latest == egui::Theme::Light); + } + } + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index da913a8..1eca07c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,24 +1,33 @@ //! Small custom widgets in the studio style. +use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; + use crate::icons; use crate::theme::{Tokens, bold, regular}; use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +/// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar. +fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle { + let t = Tokens::current(); + let visuals = |fill, icon| ButtonVisuals { fill, stroke: Stroke::new(1.0, t.button_border), icon }; + IconButtonStyle { + normal: visuals(t.button, normal_icon), + hovered: visuals(t.button_hi, normal_icon), + pressed: None, + selected: visuals(selected_fill, Color32::WHITE), + corner_radius: CornerRadius::same(radius), + focus_stroke: Stroke::new(1.0, t.accent), + } +} + /// A toolbar button with an icon; `on` draws the blue selected state. pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response { let t = Tokens::current(); - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - t.accent - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); - let col = if on { Color32::WHITE } else { t.text }; - let s = r.height().min(r.width()) * 0.78; - icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col); + let style = button_style(t.accent, t.text, 3); + let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + let s = r.height().min(r.width()) * 0.78; + icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint); + }); resp.on_hover_text(tip) } @@ -39,24 +48,22 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color resp.on_hover_text(tip) } -/// Record-enable button (red dot). +/// Record-enable button (red dot); the caller owns track arming and command dispatch. pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { - let t = Tokens::current(); - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - t.rec - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); - ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) }); + let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2); + let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + ui.painter().circle_filled(r.center(), size.y * 0.24, tint); + }); resp.on_hover_text(tip) } /// A dropdown-looking selector box. Returns the response (caller opens a menu on click). pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response { + selector_box_with_tooltip(ui, width, height, text, color, text) +} + +/// A selector with a compact visible label and the complete meaning on hover. +pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); ui.painter().rect( @@ -68,10 +75,15 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col ); let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); - let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0)); - let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5); + let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0)); + let mut job = egui::text::LayoutJob::simple(text.to_owned(), regular((height * 0.62).clamp(8.0, 12.0)), color, text_rect.width().max(1.0)); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('…'); + let galley = ui.painter().layout_job(job); + let pos = text_rect.center() - galley.size() * 0.5; ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color); - resp + resp.on_hover_text(tooltip) } /// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. @@ -371,3 +383,201 @@ mod tests { } } } + +#[cfg(test)] +mod selector_visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + #[test] + fn capture_selector_visual_fixtures() { + let _palette = crate::theme::lock_test_palette(); + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 1.5, 2.0] { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 120.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, regions: &mut serde_json::Map| { + if !ready { + crate::fonts::install(ui.ctx()); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + regions.clear(); + for (index, label) in ["auto read", "no group", "A very long mixer group name"].into_iter().enumerate() { + ui.horizontal(|ui| { + for width in [46.0, 74.0, 110.0] { + let r = selector_box(ui, width, 16.0, label, Tokens::current().text).rect; + regions.insert(format!("selector-{index}-{width}"), serde_json::json!([r.left(), r.top(), r.width(), r.height()])); + } + }); + } + }, + serde_json::Map::new(), + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("soundcraft-selectors-dark-{scale}x"); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&serde_json::json!({ + "screen": [320.0, 120.0], "scale": scale, "regions": harness.state() + })) + .unwrap(), + ) + .unwrap(); + } + } + + fn mixer_app(narrow: bool, mode: soundcraft_model::AutomationMode) -> crate::SoundApp { + let mut app = crate::SoundApp::new(soundcraft_engine::Engine::default(), None, Default::default()); + app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap(); + app.run("track.new", serde_json::json!({"name": "Music"})).unwrap(); + let ids: Vec<_> = app.engine.session().tracks.iter().map(|track| track.id).collect(); + for id in ids { + app.run("mix.automation_mode", serde_json::json!({"track": id.0, "mode": mode.label()})).unwrap(); + } + app.ui.narrow_mix = narrow; + app.ui.show_tracks_list = false; + app.ui.mix_views = vec!["io".into()]; + app + } + + #[test] + fn actual_mixer_keeps_every_active_automation_mode_legible() { + let _palette = crate::theme::lock_test_palette(); + for narrow in [false, true] { + for mode in soundcraft_model::AutomationMode::ALL { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + crate::theme::apply(&ctx, false); + let mut app = mixer_app(narrow, mode); + let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(320.0, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let long = format!("auto {}", mode.label()); + let short = if mode == soundcraft_model::AutomationMode::TouchLatch { "t/l" } else { mode.label() }; + let texts: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) + if text.galley.job.text == long || text.galley.job.text == mode.label() || text.galley.job.text == short => + { + Some(text) + } + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 2, "both tracks paint their automation mode: {mode:?}"); + for text in texts { + assert_eq!(text.galley.rows.len(), 1, "{mode:?} narrow={narrow} at {scale}x: automation text wraps"); + assert!( + !text.galley.elided, + "{mode:?} narrow={narrow} at {scale}x: the active mode must remain distinguishable without hovering" + ); + } + } + } + } + } + + #[test] + fn capture_actual_mixer_visual_fixtures() { + let _palette = crate::theme::lock_test_palette(); + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for mode in soundcraft_model::AutomationMode::ALL { + for narrow in [false, true] { + for scale in [1.0, 1.5, 2.0] { + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(320.0, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut crate::SoundApp| { + if !ready { + crate::fonts::install(ui.ctx()); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + crate::mix_window::show(app, ui); + }, + mixer_app(narrow, mode), + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("soundcraft-mixer-narrow-{narrow}-{}-{scale}x", mode.label().replace('/', "-")); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let strips = harness + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) + if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 => + { + Some(rect.rect) + } + _ => None, + }) + .fold(Rect::NOTHING, Rect::union); + assert!(strips.is_finite()); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&serde_json::json!({ + "screen": [320.0, 640.0], "scale": scale, "mode": mode.label(), "narrow": narrow, + "regions": {"mixer-selectors": [strips.left(), strips.top(), strips.width(), 120.0]} + })) + .unwrap(), + ) + .unwrap(); + } + } + } + } + + #[test] + fn compact_mixer_selectors_keep_labels_on_one_visible_line() { + let _palette = crate::theme::lock_test_palette(); + for width in [46.0, 74.0, 110.0] { + for label in ["auto read", "no group", "A very long mixer group name"] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + crate::theme::apply(&ctx, false); + let mut rect = Rect::NOTHING; + let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| { + rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect; + }); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(320.0, 120.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text == label => Some(text), + _ => None, + }) + .expect("selector label is painted"); + assert_eq!(text.galley.rows.len(), 1, "{label:?} at {width} must not wrap into clipped rows"); + let bounds = text.visual_bounding_rect(); + assert!(bounds.top() >= rect.top() && bounds.bottom() <= rect.bottom(), "{label:?}: {bounds:?} outside {rect:?}"); + assert!( + bounds.left() >= rect.left() && bounds.right() <= rect.right() - 16.0 * 0.8, + "text must leave room for the dropdown arrow" + ); + } + } + } + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 96677a6..1e51a2d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -383,10 +383,15 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { if ui.button(format!("{} {name}", i + 1)).clicked() && let Ok(mut st) = serde_json::from_value::(v.clone()) { - st.configurations = app.ui.configurations.clone(); - st.theme = app.ui.theme; - st.show_configurations = true; - app.ui = st; + if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) { + app.ui.status = format!("Window configuration could not be restored: {error}"); + } else { + st.configurations = app.ui.configurations.clone(); + st.theme = app.ui.theme; + st.show_configurations = true; + crate::docking::cancel_replaced_state(app, &st); + app.ui = st; + } } if ui.small_button("✕").clicked() { app.ui.configurations.remove(i); @@ -446,11 +451,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let r = ui.text_edit_singleline(&mut edit); ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); if r.lost_focus() && edit != name { - let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit})); + let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit})); } ui.label(fmt); if ui.small_button("Delete").clicked() { - let _ = app.run("mix.bus_delete", json!({"bus": name})); + let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name})); } ui.end_row(); } diff --git a/crates/ui-egui/tests/appearance.rs b/crates/ui-egui/tests/appearance.rs new file mode 100644 index 0000000..87145a8 --- /dev/null +++ b/crates/ui-egui/tests/appearance.rs @@ -0,0 +1,47 @@ +use egui::accesskit::Role; +use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp}; + +const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0); + +fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option) { + let zoom = h.ctx.zoom_factor(); + h.set_size(WINDOW_PX / zoom); + let pos = (px.to_vec2() / zoom).to_pos2(); + h.input_mut().events.push(Event::PointerMoved(pos)); + if let Some(pressed) = pressed { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); +} + +#[test] +fn interface_scale_slider_follows_the_pointer() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.extra.show_ui_customization = true; + let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let rail = h.get_by_role(Role::Slider).rect(); + let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y); + let end = pos2(rail.right() + 40.0, rail.center().y); + frame(&mut h, start, Some(true)); + let mut scales = vec![]; + for i in 1..=20 { + frame(&mut h, start.lerp(end, i as f32 / 20.0), None); + scales.push(h.state().extra.ui_scale); + } + assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}"); + frame(&mut h, end, Some(false)); + frame(&mut h, end, None); + assert_eq!(h.state().extra.ui_scale, 1.5); + assert_eq!(h.ctx.zoom_factor(), 1.5); +} diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/docking_workflows.rs new file mode 100644 index 0000000..c2cdf33 --- /dev/null +++ b/crates/ui-egui/tests/docking_workflows.rs @@ -0,0 +1,159 @@ +//! Real Edit/Mix panel workflows, persistence, and command ownership without OS windows. +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState, docking::Panel}; + +fn harness(state: Option, width: f32, scale: f32) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name":"Voice"})).unwrap(); + if let Some(state) = state { + app.ui = state; + } + let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(2); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + for step in 1..=8 { + h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); +} + +#[test] +fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { + let mut h = harness(None, 1400.0, 1.0); + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").rect().center(); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center(); + drag(&mut h, source, target); + assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks)); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary); + h.run_steps(3); + h.get_by_label("Float panel").click(); + h.run_steps(5); + let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect; + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary); + h.run_steps(3); + h.get_by_label("Close panel").click(); + h.run_steps(4); + assert!(!h.state().ui.show_clip_list); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect); + let saved = serde_json::to_string(&h.state().ui).unwrap(); + let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); + let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected); + reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap(); + reloaded.run_steps(4); + reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click(); + reloaded.run_steps(3); + reloaded.get_by_label("Trim (F6)").click(); + reloaded.run_steps(3); + assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); + assert!(!reloaded.state().engine.transport.playing); + let edit = serde_json::to_value(&reloaded.state().ui.docking).unwrap(); + reloaded.state_mut().ui.window = MainWindow::Mix; + reloaded.run_steps(5); + reloaded.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[900,70,220,420]})).unwrap(); + reloaded.run_steps(4); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit); + assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips)); + let record = reloaded.get_by_label("Record enable"); + // A real pointer click must remain outside the floating panel's occluding rectangle. + assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center())); + record.click(); + reloaded.run_steps(3); + assert!(reloaded.state().engine.session().tracks[0].mixer.record_arm); + assert!(!reloaded.state().engine.transport.recording); +} + +#[test] +fn invalid_docking_requests_are_atomic_in_edit_and_mix() { + let mut h = harness(None, 1400.0, 1.0); + for window in [MainWindow::Edit, MainWindow::Mix] { + h.state_mut().ui.window = window; + h.run_steps(4); + let before = serde_json::to_value(&h.state().ui).unwrap(); + for params in [ + json!({"operation":"close","panel":"canvas"}), + json!({"operation":"move","panel":"tracks","target":"canvas","zone":"center"}), + json!({"operation":"float","panel":"tracks","rect":[0,0,-1,100]}), + ] { + assert!(h.state_mut().run("ui.dock", params).is_err()); + assert_eq!(serde_json::to_value(&h.state().ui).unwrap(), before); + } + if window == MainWindow::Mix { + assert!(h.state_mut().run("ui.dock", json!({"operation":"open","panel":"midi"})).is_err()); + } + assert_eq!(h.state().engine.session().tracks.len(), 1); + } +} + +#[test] +fn docking_renders_edit_and_mix_default_floating_and_redocked() { + for width in [1000.0, 1400.0] { + for scale in [1.0, 2.0] { + for window in [MainWindow::Edit, MainWindow::Mix] { + let mut h = harness(Some(UiState { window, ..Default::default() }), width, scale); + h.state_mut().engine = soundcraft_engine::demo::demo_engine(); + for state in ["default", "floating", "redocked"] { + if state == "floating" { + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[100,80,260,500]})).unwrap(); + } else if state == "redocked" { + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap(); + } + h.run_steps(5); + h.state().ui.docking.validate().unwrap(); + h.state().ui.mix_docking.validate().unwrap(); + if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap(); + } + } + } + } + } +} + +#[test] +fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() { + use soundcraft_ui_egui::menus; + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let path = "Window > Reset Panel Layout"; + let extra: Vec<_> = menus::UI_COMMANDS.iter().map(|(id, _, path, _)| (*path, *id)).collect(); + assert_eq!(menus::command_for_path(path, &extra).as_deref(), Some("ui.dock.reset")); + let tree = menus::tree(); + let window = tree.iter().find(|node| node.label == "Window").unwrap(); + assert!(window.children.iter().any(|node| node.path == path)); + assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0); + app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap(); + menus::invoke_menu(&mut app, "ui.dock.reset", path); + assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks))); +} + +#[test] +fn legacy_preferences_restore_the_theme_and_validate_default_docking() { + let mut value = serde_json::to_value(UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, ..Default::default() }).unwrap(); + let object = value.as_object_mut().unwrap(); + object.remove("docking"); + object.remove("mix_docking"); + let restored: UiState = serde_json::from_value(value).unwrap(); + assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light); + restored.docking.validate().unwrap(); + restored.mix_docking.validate_mix().unwrap(); +} diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs new file mode 100644 index 0000000..56cb12d --- /dev/null +++ b/crates/ui-egui/tests/midi_panel.rs @@ -0,0 +1,152 @@ +use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, midi_editor}; + +fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +fn panel(ctx: &egui::Context) -> Rect { + ctx.data(|data| data.get_temp::(Id::new("soundcraft-midi-body"))).unwrap() +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn check_resize(with_clip: bool) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + if with_clip { + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + } + assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip); + let ctx = egui::Context::default(); + for _ in 0..6 { + frame(&ctx, &mut app, vec![]); + } + let initial = panel(&ctx); + assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}"); + let notes = app.engine.session().clone(); + + // Drag the shared boundary up and down, then allow several idle frames + // to catch a panel that looks resized but snaps back after release. + for height in [440.0, 280.0, 200.0] { + let before = panel(&ctx); + let start = pos2(before.center().x, before.top() - 26.0 - 2.0); + let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); + frame(&ctx, &mut app, vec![button(start, true)]); + for step in 1..=6 { + frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]); + } + frame(&ctx, &mut app, vec![button(end, false)]); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + let after = panel(&ctx); + assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}"); + assert!((after.bottom() - initial.bottom()).abs() < 1.0); + assert!(app.edit_layout.timeline[3] <= after.top()); + } + + // Closing and reopening the dock keeps the chosen height. + let resized = panel(&ctx); + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + app.run("window.midi_editor", json!({})).unwrap(); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(panel(&ctx), resized); + let saved = serde_json::to_string(&app.ui).unwrap(); + app.ui = serde_json::from_str(&saved).unwrap(); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(panel(&ctx), resized); + assert_eq!(*app.engine.session(), notes); +} + +#[test] +fn midi_panel_resizes_with_a_selected_clip() { + check_resize(true); +} + +#[test] +fn midi_panel_resizes_without_a_selected_clip() { + check_resize(false); +} + +#[test] +fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("reset"); +} + +#[test] +fn arrange_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("arrange"); +} + +#[test] +fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("visibility"); +} + +fn check_held_replacement(operation: &str) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks":["Keys"]})).unwrap(); + app.ui.show_memory_locations = operation == "arrange"; + let session = app.engine.session().clone(); + let ctx = egui::Context::default(); + for _ in 0..6 { + frame(&ctx, &mut app, vec![]); + } + let initial = panel(&ctx); + let start = pos2(initial.center().x, initial.top() - 28.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); + frame(&ctx, &mut app, vec![button(start, true)]); + let first = start - vec2(0.0, 80.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(first)]); + frame(&ctx, &mut app, vec![]); + assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize"); + let second = start - vec2(0.0, 120.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(second)]); + frame(&ctx, &mut app, vec![]); + assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing"); + match operation { + "reset" => { + app.run("ui.dock.reset", json!({})).unwrap(); + } + "arrange" => { + app.run("window.arrange_cascade", json!({})).unwrap(); + } + "visibility" => { + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + } + _ => panic!("unknown synthetic operation"), + } + let reset = serde_json::to_value(&app.ui.docking).unwrap(); + let after_reset = start - vec2(0.0, 160.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]); + frame(&ctx, &mut app, vec![]); + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout"); + let expected = if operation == "reset" { 320.0 } else { 440.0 }; + assert!((panel(&ctx).height() - expected).abs() < 1.0); + frame(&ctx, &mut app, vec![button(after_reset, false)]); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset); + assert_eq!(*app.engine.session(), session); +} diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs new file mode 100644 index 0000000..fb33610 --- /dev/null +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -0,0 +1,172 @@ +use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; + +#[derive(Debug)] +struct Slot { + rect: Rect, + fill: Color32, + text: Color32, + border: Color32, +} + +// Read the rectangle and foreground emitted by the real Mix drawing path, rather than +// testing a palette token that a custom-painted control might never use. +fn slot(output: &FullOutput, label: &str) -> Slot { + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Text(text) if text.galley.job.text == label => Some(text), + _ => None, + }) + .unwrap_or_else(|| panic!("missing painted label {label}")); + let center = text.visual_bounding_rect().center(); + let rect = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect), + _ => None, + }) + .unwrap_or_else(|| panic!("missing painted slot for {label}")); + let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color); + assert_eq!(text.opacity_factor, 1.0); + assert_eq!(foreground.a(), 255); + assert_eq!(rect.fill.a(), 255); + Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color } +} + +fn contrast(a: Color32, b: Color32) -> f64 { + let luminance = |color: Color32| { + let linear = |channel: u8| { + let s = f64::from(channel) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +#[test] +fn populated_mix_instrument_uses_readable_palette_in_real_frames() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + assert_eq!(app.ui.theme, ThemeMode::Dark); + // Keep demo audio loaded for the preservation check while isolating the new strips. + for track in &mut app.engine.session_mut().tracks { + track.hidden = true; + } + app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap(); + let instrument = app.engine.session().edit.selected_tracks[0]; + assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth"); + app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap(); + app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap(); + // An unassigned instrument is a supported model state; track.new normally supplies a synth. + let empty = app.engine.session().edit.selected_tracks[0]; + app.engine.session_mut().track_mut(empty).unwrap().instrument = None; + app.run("window.mix", json!({})).unwrap(); + app.run("view.mix_section", json!({"section": "minimal"})).unwrap(); + for section in ["instruments", "inserts_ae"] { + app.run("view.mix_section", json!({"section": section})).unwrap(); + } + let session = app.engine.session().clone(); + assert!(!session.pool.is_empty(), "the fixture must include decoded audio"); + let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from); + let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0); + if proof_dir.is_some() { + builder = builder.wgpu(); + } + let mut harness = builder.build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + let mut failures = Vec::new(); + for (name, mode, system, light) in [ + ("light", ThemeMode::Light, Some(egui::Theme::Dark), true), + ("dark", ThemeMode::Dark, Some(egui::Theme::Light), false), + ("system-light", ThemeMode::System, Some(egui::Theme::Light), true), + ("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false), + ] { + harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap(); + harness.input_mut().system_theme = system; + harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0))); + for _ in 0..4 { + harness.step(); + } + let ordinary = slot(harness.output(), "SubSynth"); + let insert = slot(harness.output(), "EQ7"); + let empty = slot(harness.output(), "no instrument"); + let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK }; + assert_eq!(insert.fill, palette.insert_on); + assert_eq!(empty.fill, palette.slot_bg); + assert_eq!(ordinary.text, palette.text); + if ordinary.fill != insert.fill || ordinary.border != insert.border { + failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}")); + } + for hovered in [false, true] { + if hovered { + harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center())); + harness.step(); + harness.step(); + } + let painted = slot(harness.output(), "SubSynth"); + let ratio = contrast(painted.text, painted.fill); + println!( + "{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}", + painted.text.to_array(), + painted.fill.to_array(), + painted.rect + ); + if ratio < 4.5 { + failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1")); + } + if !light { + let original = Color32::from_rgb(52, 62, 80); + assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original }); + assert_eq!(painted.border, Color32::from_rgb(16, 16, 16)); + } + if let Some(dir) = &proof_dir { + harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap(); + } + } + assert_eq!(harness.state().ui.theme, mode); + assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + + // Exercise the same painted control's ordinary click/picker path after palette changes. + let at = slot(harness.output(), "SubSynth").rect.center(); + for pressed in [true, false] { + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + harness.step(); + } + harness.step(); // Let the popup finish its initial sizing pass. + harness.get_by_label_contains("Built-in").hover(); + harness.step(); + harness.step(); + harness.get_by_label("Drum Synth").click(); + harness.step(); + harness.step(); + let app = harness.state(); + let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap(); + assert_eq!(replacement.plugin, "drum_synth"); + for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params { + assert_eq!(replacement.params.get(param.id), Some(¶m.default)); + } + assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks); + assert_eq!(app.engine.session().pool, session.pool); + assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker"); + harness.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(harness.state().engine.session(), &session); +} diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/shared_controls.rs new file mode 100644 index 0000000..71ae5de --- /dev/null +++ b/crates/ui-egui/tests/shared_controls.rs @@ -0,0 +1,217 @@ +//! Interaction and optional offscreen evidence for the application's painted controls. + +use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp, theme, widgets}; + +#[derive(Default)] +struct ButtonState { + record: bool, + selected: bool, + disabled: bool, + clicks: usize, + response: Option, +} + +impl ButtonState { + fn label(&self) -> &'static str { + if self.record { "Record enable" } else { "Loop playback" } + } +} + +fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec) -> egui::FullOutput { + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + let response = ui + .add_enabled_ui(!state.disabled, |ui| { + if state.record { + widgets::rec_toggle(ui, vec2(38.0, 28.0), state.selected, state.label()) + } else { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", state.selected, state.label()) + } + }) + .inner; + state.clicks += usize::from(response.clicked()); + state.response = Some(response); + }); + output.textures_delta.clear(); + output +} + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +fn pointer(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn tool_and_record_buttons_preserve_distinct_selected_colors_and_caller_state() { + for record in [false, true] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { record, selected: true, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + let center = state.response.as_ref().unwrap().rect.center(); + let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + let expected = if record { theme::Tokens::current().rec } else { theme::Tokens::current().accent }; + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == expected))); + if record { + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == egui::Color32::WHITE))); + } + let update = out.platform_output.accesskit_update.unwrap(); + let node = &update.nodes.iter().find(|(_, n)| n.label() == Some(state.label())).unwrap().1; + assert_eq!(node.role(), egui::accesskit::Role::Button); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True)); + button_frame(&ctx, &mut state, vec![pointer(center, false)]); + assert_eq!(state.clicks, 1); + assert!(state.selected, "selection and record arming remain caller-owned"); + } +} + +#[test] +fn tool_and_record_buttons_handle_keyboard_disabled_state_and_cancelled_clicks() { + for record in [false, true] { + for key_code in [Key::Enter, Key::Space] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { record, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + button_frame(&ctx, &mut state, vec![]); + let response = state.response.clone().unwrap(); + ctx.memory_mut(|m| m.request_focus(response.id)); + let out = button_frame(&ctx, &mut state, vec![]); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) + if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::current().accent))); + button_frame(&ctx, &mut state, vec![key(key_code, true)]); + button_frame(&ctx, &mut state, vec![key(key_code, false)]); + assert_eq!(state.clicks, 1); + let center = response.rect.center(); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]); + button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]); + assert_eq!(state.clicks, 1); + state.disabled = true; + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]); + let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]); + assert_eq!(state.clicks, 1); + assert!(!state.response.as_ref().unwrap().enabled()); + assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some(state.label()) && n.is_disabled())); + } + } +} + +#[test] +fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions() { + let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Trim (F6)").click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); + let markers = h.state().engine.session().markers.len(); + for (label, tool, key) in + [("Pencil (F10)", soundcraft_model::Tool::Pencil, Key::Enter), ("Grabber (F8)", soundcraft_model::Tool::Grabber, Key::Space)] + { + h.get_by_label(label).focus(); + h.run_steps(2); + assert!(h.get_by_label(label).is_focused()); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().engine.session().edit.tool, tool); + assert!(!h.state().engine.transport.playing); + assert!(!h.state().engine.transport.recording); + assert_eq!(h.state().engine.session().markers.len(), markers); + } +} + +#[test] +fn track_record_button_arms_the_track_without_starting_transport() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Record enable").click(); + h.run_steps(3); + assert!(h.state().engine.session().tracks[0].mixer.record_arm); + for (key, armed) in [(Key::Enter, false), (Key::Space, true)] { + h.get_by_label("Record enable").focus(); + h.run_steps(2); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.record_arm, armed); + assert!(!h.state().engine.transport.playing); + assert!(!h.state().engine.transport.recording); + assert!(h.state().engine.session().markers.is_empty()); + } +} + +#[test] +fn shared_controls_visuals() { + let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 2.0] { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(1000.0, 700.0))] { + h.set_size(size); + h.run_steps(6); + h.render().unwrap().save(directory.join(format!("soundcraft-{name}-dark-{scale}x.png"))).unwrap(); + } + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, rects: &mut Vec| { + theme::apply(ui.ctx(), false); + rects.clear(); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + rects.push(widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop Playback").rect); + rects.push(widgets::icon_button(ui, vec2(36.0, 26.0), "trim", selected, "Trim").rect); + rects.push(widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable").rect); + } + }); + }); + } + }, + Vec::new(), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-dark-{scale}x.png"))).unwrap(); + for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] { + let center = controls.state()[index].center(); + controls.event(Event::PointerMoved(center)); + if pressed { + controls.event(pointer(center, true)); + } + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-{name}-dark-{scale}x.png"))).unwrap(); + } + controls.event(pointer(controls.state()[0].center(), false)); + controls.event(Event::PointerGone); + controls.run_steps(2); + controls.key_press(Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-focus-dark-{scale}x.png"))).unwrap(); + } +} diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 0844e66..5d04d47 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -1,6 +1,7 @@ +use egui::os::OperatingSystem; use egui::{Key, Modifiers}; use serde_json::json; -use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts}; fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); @@ -9,14 +10,37 @@ fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers #[test] fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { - let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); - let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; - app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); - let ctx = egui::Context::default(); - ctx.set_os(egui::os::OperatingSystem::Mac); - let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; - press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); - press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); - press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); - assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0); + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_os(os); + let ctrl_shift = Modifiers { shift: true, ..ctrl }; + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}"); + } +} + +#[test] +fn cmd_equals_toggles_mix_and_edit_without_zooming() { + let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(os); + ctx.set_zoom_factor(1.25); + press(&ctx, &mut app, Key::Equals, cmd); + assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}"); + press(&ctx, &mut app, Key::Equals, cmd); + press(&ctx, &mut app, Key::Num0, cmd); + press(&ctx, &mut app, Key::Minus, cmd); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}"); + assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}"); + } } diff --git a/crates/ui-egui/tests/theme_docking.rs b/crates/ui-egui/tests/theme_docking.rs new file mode 100644 index 0000000..ed32415 --- /dev/null +++ b/crates/ui-egui/tests/theme_docking.rs @@ -0,0 +1,83 @@ +//! Synthetic appearance checks for populated shared panels and custom controls. +use egui::{Color32, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme, widgets}; + +#[test] +fn shared_panels_and_controls_follow_manual_and_live_system_appearance() { + // Keep palette changes in one owning test: upstream custom painters use process-global tokens. + for scale in [1.0, 2.0] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks":["Keys"]})).unwrap(); + for id in ["window.midi_editor", "window.memory_locations", "window.undo_history"] { + app.run(id, json!({})).unwrap(); + } + let session = app.engine.session().clone(); + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + for (name, mode, system, light) in [ + ("dark", theme::ThemeMode::Dark, egui::Theme::Light, false), + ("light", theme::ThemeMode::Light, egui::Theme::Dark, true), + ("system-light", theme::ThemeMode::System, egui::Theme::Light, true), + ("system-dark", theme::ThemeMode::System, egui::Theme::Dark, false), + ] { + h.state_mut().ui.theme = mode; + h.input_mut().system_theme = Some(system); + for (width_name, size) in [("normal", vec2(1400.0, 1000.0)), ("narrow", vec2(1000.0, 800.0))] { + h.set_size(size); + h.run_steps(5); + assert_eq!(theme::is_light(), light); + assert_eq!(h.state().ui.theme, mode); + assert_eq!(*h.state().engine.session(), session); + h.state().ui.docking.validate().unwrap(); + if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap(); + } + } + let ctx = egui::Context::default(); + theme::apply(&ctx, light); + let tokens = theme::Tokens::current(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback"); + widgets::rec_toggle(ui, vec2(30.0, 18.0), true, "Record enable"); + ui.add_enabled_ui(false, |ui| widgets::icon_button(ui, vec2(38.0, 28.0), "loop", false, "Disabled loop")); + }); + output.textures_delta.clear(); + for expected in [tokens.accent, tokens.rec] { + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == expected))); + } + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Circle(circle) if circle.fill == Color32::WHITE))); + if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) { + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, _: &mut ()| { + theme::apply(ui.ctx(), light); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop playback"); + widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable"); + } + }); + }); + } + }, + (), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("sound-controls-{name}-{scale}x.png"))).unwrap(); + controls.key_press(egui::Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("sound-controls-focus-{name}-{scale}x.png"))).unwrap(); + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 3432699..a041434 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,21 @@ # Architecture +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate diagram brought up to date: video, vst3-host, au-host; device I/O limits) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. +The UI package pins `craft-ui` for common icon-button and track record-enable interactions. +SoundCraft supplies each state's colors, border, radius and icon painter; selection and command +dispatch remain in the toolbar and track controls. The single-shape frame option preserves the +existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling, +nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI +requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration. + ``` -L0 time audio-io midi (standalone: no workspace deps) -L1 dsp clap-host (plugins; clap-host is the only unsafe crate) +L0 time audio-io midi video (standalone: no workspace deps) +L1 dsp clap-host vst3-host au-host (plugins; the three hosts are the only crates + allowed `unsafe`, confined to their FFI modules) L2 model (the session document) L3 mix (the mix engine) L4 engine playback (commands, undo, I/O · audio devices) @@ -41,6 +51,16 @@ process in parallel; plugin delay compensation aligns every path. The same engin bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns it on the audio thread and receives new session snapshots through a channel). +Device I/O today is the system's *default* output and *default* input device, opened as two +separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when +they differ. There is no device or buffer-size selection, no ASIO, and no MIDI device layer yet +(see [`hardware-parity.md`](hardware-parity.md)). Sources are decoded whole into the `SourcePool` +and recordings are captured in memory until stop. + +`soundcraft-video` holds our own H.264, ProRes and Motion JPEG decoders and the ISO-BMFF demuxer +for the video track. Hosted plugins (`clap-host`, `vst3-host`, `au-host`) are scanned, created and +destroyed off the audio thread; only `process` runs on it. + The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does, so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted @@ -52,3 +72,10 @@ by the UI thread (see README › Logs). The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection, synthetic input and screenshots; `soundcraft-cli mcp` wraps either a headless engine or a running app as an MCP server. See `control-protocol.md` and `mcp.md`. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Added video, vst3-host and au-host to the layer diagram; documented device I/O limits and in-memory media | +| 2026-10-05 | major | First architecture overview | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ba7b079..32a4932 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -12,6 +12,18 @@ input), and every command is reachable from: | MCP (headless) | `soundcraft-cli mcp [--demo]` | | MCP (bridged to the app) | `soundcraft-cli mcp --connect ` | +## Isolated automation profiles + +Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`. +The nonempty value is used as the complete settings directory. Unset retains the normal platform paths; +an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS` +when testing preference persistence across restarts. + +Run UI acceptance with `--no-audio`. This disables the output player; it does not disable recording +input opened by arm/monitor commands. An automated UI smoke run must never arm a track, enable input +monitoring, or start recording. Control input injects egui events; it does not establish operating-system +input delivery or screen-reader acceptance. Native screenshots use the presented viewport frame. + ## Wire format Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line: @@ -64,4 +76,30 @@ soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}' Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`, where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or -`{"steps": n}`. +`{"steps": n}`. Use `--system-theme light|dark|none` to supply the initial OS appearance, and +script events such as `{"system_theme": "dark"}` to change it on subsequent frames. + +Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by +the native or web integration at launch and on later frames, while the saved choice stays System. +When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay +fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no +OS appearance and therefore resolves System to Dark. + +## Panel docking + +Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. + +Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`. + +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. + +Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`. + +Advanced `ui.dock` operations use the same validated transaction as pointer gestures: +`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and +`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), +`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. +A center `move` can include `before` (panel ID or null to append) for tab order. +The equivalent `{"action": ...}` form accepts a serialized shared docking action; +this is also the form emitted by the renderer. Protected-canvas and app-specific panel +rules apply equally to both forms. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 0000000..5149502 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,83 @@ +# File-format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format inventory against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types +its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its +documented import/bounce options. Ours comes from `crates/audio-io` (`detect_format`, `decode`, +`ENCODE_EXTENSIONS`), `crates/engine/src/cmd/file.rs`, `crates/engine/src/score.rs`, +`crates/engine/src/clip_group_file.rs` and `crates/video`. + +**File formats: ~40 % ready** (estimated: weighted by how often a Pro Tools user hits each row; +session interchange carries a third of the weight and is at 0 %). Remaining ≈ 70–120 h. + +Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not applicable. + +## Sessions and interchange + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| Pro Tools session `.ptx` (and `.ptf` from 7–9) | native | ❌ | ❌ | Proprietary, undocumented. Owner decision needed on black-box format study (G-03) | +| Session templates `.ptxt` | read/write | ❌ | ❌ | We have no templates at all (G-16) | +| AAF (Advanced Authoring Format) | import/export | ❌ | ❌ | #40. The post-production handoff from picture editors (G-03) | +| OMF | import/export | ❌ | ❌ | Legacy, still delivered by some editors | +| Media Composer–compatible session | export | ❌ | ❌ | AAF variant | +| ADM BWF (Dolby Atmos master) | import/export | ❌ | ❌ | G-12 | +| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded | +| Import Session Data (from another session) | Pro Tools sessions | 🟡 | — | From `.scraft` only | +| Session info as text | export | — | ✅ | `file.export_session_text` | +| Clip groups `.rgrp` / ours `.scgrp` | read/write | 🟡 | 🟡 | Our own `.scgrp` format; Pro Tools' clip-group files not read | +| Track presets, fade presets, I/O settings, workspace, sync settings | read/write | ❌ | ❌ | Their equivalents don't exist yet (G-16) | + +## Audio + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read | +| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` | +| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) | +| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native | +| FLAC | import | ✅ | ✅ | Own encoder | +| MP3 | import, bounce | ✅ (symphonia) | ❌ | G-21 | +| AAC / M4A / MP4 audio | import, bounce | ✅ (symphonia) | ❌ | G-21 | +| ALAC | import | ✅ | ❌ | | +| Ogg Vorbis | — | ✅ | ❌ | Beyond Pro Tools | +| CAF | — | ✅ | ❌ | | +| Sound Designer / SD II | import (legacy) | ❌ | — | Rare today | +| ReCycle / REX | import | ❌ | — | Loops | +| MXF audio (OP-Atom, OP1a) | import | ❌ | ❌ | Avid media; FilmCraft has an MXF reader to learn from | +| iXML / polyphonic field-recorder metadata | read | ❌ | ❌ | G-17 | +| Peak overviews | `.wfm` cache | ✅ (own, in memory) | — | `audio-io::peaks`; not cached on disk | + +## MIDI and notation + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| Standard MIDI File (type 0/1) | import/export | ✅ | 🟡 | Exported MIDI omits imported controllers and ignores the tracks filter (#87, #98; PR #125) | +| MusicXML (to Sibelius) | export | — | ✅ | `file.export_sibelius`, `score.rs` | +| Printable score | print | — | ✅ (SVG) | | + +## Video + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| QuickTime / MP4, H.264 | import, bounce | ✅ | ❌ | Own pure-Rust decoder (Baseline/Main/High); mutated-movie fuzzing | +| ProRes (all flavours) | import | ✅ | ❌ | Own decoder | +| Motion JPEG | import | ✅ | ❌ | | +| HEVC, AV1 | import | ❌ | ❌ | G-17 | +| DNxHD / DNxHR, MXF video | import | ❌ | ❌ | G-17 | +| Bounce with video | export | — | ❌ | G-17 | + +## Plugin and preset files + +| Format | Pro Tools | Ours | +|---|---|---| +| AAX plug-ins | the only format it hosts | ❌ (cannot: Avid SDK licence, PACE signing) | +| VST3 / Audio Units / CLAP | — (AAX only) | ✅ hosted (ahead of Pro Tools) | +| Plug-in settings `.tfx` | read/write | ❌ (our own JSON presets per plugin) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 0000000..cf3ea5b --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,200 @@ +# Where SoundCraft falls short of Pro Tools + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha blockers marked after the core-workflow gate) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing +real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** +fail the core-workflow gate in [`roadmap.md`](roadmap.md#alpha-gate) and keep SoundCraft pre-alpha. Numbers and weights live in +[`target-app-parity.md`](target-app-parity.md); the plan that orders these into milestones is +[`roadmap.md`](roadmap.md). Hours are Opus 5.5 agent-hours (calibration in target-app-parity.md). +Estimates marked "≈" are judgements; issue numbers are `storytold/soundcraft` GitHub issues. + +When you close a gap, delete its entry (or shrink it) in the same PR, and bump the timestamp. + +## Ranked gaps + +| # | Gap | Dimension | Doc | Est. (h) | +|---|---|---|---|---:| +| G-01 | **Alpha blocker.** No audio device, channel or buffer-size selection; no duplex low-latency path | Hardware | [hardware-parity.md](hardware-parity.md) | 25–40 | +| G-02 | **Alpha blocker.** No MIDI device input or output | Hardware / MIDI | [hardware-parity.md](hardware-parity.md) | 20–35 | +| G-03 | **Alpha blocker.** Cannot exchange sessions with Pro Tools (no AAF/OMF, no `.ptx`) | File formats | [file-format-parity.md](file-format-parity.md) | 50–90 | +| G-04 | Open correctness bugs in features counted as done (~25 issues) | Stability | this file | 25–40 | +| G-05 | (Entitlement part: alpha blocker for the mix row) Third-party VST3s blocked in the signed macOS build; AU editors missing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 15–25 | +| G-06 | (Record-to-disk part: alpha blocker for the tracking row) Sources decoded whole into RAM; recording into RAM | Performance | [hardware-parity.md](hardware-parity.md) | 15–25 | +| G-07 | Nothing is compared against Pro Tools' output (no fidelity harness) | Features | this file | 20–30 | +| G-08 | Built-in plugin catalogue thin and unverified | Features | [plugin-parity.md](plugin-parity.md) | 80–120 | +| G-09 | English only, no string catalog, no CJK/RTL rendering | Localization | [localization-parity.md](localization-parity.md) | 70–110 | +| G-10 | No control surfaces (HUI/MCU, EUCON) | Hardware | [hardware-parity.md](hardware-parity.md) | 40–70 | +| G-11 | No external sync (MTC, LTC, MIDI clock, video reference) | Hardware | [hardware-parity.md](hardware-parity.md) | 15–25 | +| G-12 | Immersive delivery: no Dolby Atmos/ADM BWF, MPEG-H, binaural | Features | [file-format-parity.md](file-format-parity.md) | 50–80 | +| G-13 | Sidechain inputs, ARA, out-of-process plugin sandboxing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 30–50 | +| G-14 | Windows/Linux/Web exercised only lightly at runtime; no PR CI running the tests | Platforms / Stability | this file | 20–40 | +| G-15 | Elastic Audio depth: one algorithm, no Elastic Pitch | Features | [target-app-parity.md](target-app-parity.md) | 30–45 | +| G-16 | Session templates, track presets, workspace browser, batch rename | Features | [target-app-parity.md](target-app-parity.md) | 25–40 | +| G-17 | Video: HEVC/DNx decode, video export, external timecode | Features / Formats | [file-format-parity.md](file-format-parity.md) | 30–50 | +| G-18 | Speech-to-text transcription | AI | this file | 30–50 | +| G-19 | UI feel: generic plugin windows, shortcut collisions, pro-grade meters and scaling | UI/UX | [ui-parity.md](ui-parity.md) | 60–100 | +| G-20 | MIDI/instrument depth: MIDI learn, sampler, notation editing | Features | [target-app-parity.md](target-app-parity.md) | 40–60 | +| G-21 | Compressed export (MP3, AAC/M4A, Ogg/Opus) | File formats | [file-format-parity.md](file-format-parity.md) | 10–20 | +| G-22 | Avid cloud services (collaboration, Sketch, Splice, sign-in) | Ecosystem | — | not estimated (owner decision) | + +## Details + +### G-01. Audio interface support + +- **Missing:** choosing the output or input device, the device's channel count and routing to + physical channels, the buffer size, and the sample rate from the hardware side. Input and + output are separate cpal streams on the *default* devices (`crates/playback/src/lib.rs` + `open_device`, `record.rs`); the mix runs in fixed 512-sample blocks and resamples when rates + differ. `setup.playback_engine` stores `device`/`buffer_size` params, but playback ignores them. + No ASIO on Windows (WASAPI shared only), so latency there is high. +- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42 + (no input device selection, forced monitoring feedback with virtual routing). +- **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard. +- **Estimate:** ≈ 25–40 h (device enumeration and selection, buffer size, duplex stream with one + clock, ASIO feature build, per-channel I/O mapping into I/O Setup, latency reporting and + record-offset compensation). Needs hands-on hardware testing. + +### G-02. MIDI devices + +- **Missing:** all MIDI hardware I/O. No `midir`/CoreMIDI/WinMM/ALSA sequencer code exists; + `setup.midi_input_devices` and `setup.midi_studio` only store names. You cannot record from a + keyboard, play a hosted instrument live, drive external synths, or send MIDI clock/MTC. + `midi_editor.rs:288` notes audition "needs a live MIDI input path". +- **Impact:** MIDI production is limited to drawing notes and importing SMF. +- **Estimate:** ≈ 20–35 h (device layer, realtime input to armed tracks and instruments with + timestamps, MIDI record/merge/loop, thru, output tracks, clock out). + +### G-03. Session interchange with Pro Tools + +- **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media + Composer–compatible sessions. Our session format is `.scraft` JSON. +- **Impact:** the standard's main-format bar for beta. A Pro Tools user cannot bring a project in + or hand one back; post-production (picture editors deliver AAF) is blocked. +- **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn + from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether + black-box format study is in the clean-room rules; not included in the estimate. + +### G-04. Correctness bugs in "done" features + +Open on 2026-10-10 (several have PRs open): +editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132); +automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129); +MIDI #87, #98 (PR #125); import #89 (PR #128); CLI #95, #96 (PR #126, #127); MCP #145 (PR #146); +web #63, #64, #136; Clip List #4 (PR #75); plugin view #6; shortcuts #7, #41; +launch #142; Linux #45, #107. +- **Impact:** each one is small, but together they say "presence is not parity": an outside + contributor found seven in one sitting (#116–#122). +- **Estimate:** ≈ 25–40 h at 0.5–1.5 h each, plus review of the open PRs. + +### G-05. Plugin hosting on macOS + +- **Missing:** the `com.apple.security.cs.disable-library-validation` entitlement in the signed + build, so hardened runtime refuses third-party VST3 bundles (#153, #149). AU editor views + (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). Embedded CLAP + GUIs; VST3 editors on Windows/Linux. +- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it). + +### G-06. Memory-bound media + +- **Missing:** streaming sources from disk; recording to disk. Every source is decoded whole into + the `SourcePool`; takes accumulate in RAM until stop (PR #84 open). +- **Impact:** hour-long multitrack sessions and long takes exhaust memory; a crash during a long + take loses it. +- **Estimate:** ≈ 15–25 h. + +### G-07. Fidelity harness against Pro Tools + +- **Missing:** any measured comparison: null tests of bounces of the same material, fade curve + and pan-law checks, plugin frequency/dynamics responses, elastic-audio quality, automation + timing. Pro Tools is installed; it can be driven black-box with synthetic sessions (outputs kept + under `plan/`, never committed). +- **Estimate:** ≈ 20–30 h for the harness and the first 20 scored cases; turns many "≈" in + target-app-parity.md into measurements. + +### G-08. Built-in plugins + +See [`plugin-parity.md`](plugin-parity.md): 26 processors + 2 instruments against Pro Tools' +roughly 80 bundled processors and instruments; no fidelity checks; generic parameter UIs; EQ +parametric request #139. ≈ 80–120 h. + +### G-09. Localization + +See [`localization-parity.md`](localization-parity.md): no catalog, every string literal in code; +egui has no complex-script shaping or RTL; no CJK font fallback (PR #141 open). ≈ 70–110 h plus +native-speaker review. + +### G-10. Control surfaces + +None: no HUI or Mackie Control, no EUCON (Avid-proprietary, needs Avid's SDK: owner decision), +no generic MIDI controller mapping. Pro Tools users on S1/S3/S4/S6/Dock or HUI devices can't +switch. ≈ 40–70 h for HUI/MCU plus a mapping layer; hardware needed to verify. + +### G-11. Sync + +No MTC/LTC chase or generation, no MIDI Beat Clock (`setup.midi_beat_clock` stores a flag only), +no video reference or word clock awareness, no Satellite-style linking. ≈ 15–25 h after G-01/G-02. + +### G-12. Immersive delivery + +No Dolby Atmos renderer integration, ADM BWF import/export, re-renders, binaural monitoring, +MPEG-H (new in Pro Tools 2026.4), surround/object automation. ≈ 50–80 h; Dolby's renderer and +SDKs are licensed, so we'd write our own ADM and object renderer. + +### G-13. Plugin ecosystem depth + +No sidechain (key input) buses for any host; no ARA (Melodyne-style editing is a menu in Pro +Tools); plugins run in-process, so a crashing plugin can take the session down. ≈ 30–50 h. AAX +cannot be hosted (#109): Avid's AAX SDK licence and PACE signing; owner decision, likely never. + +### G-14. Platforms and CI + +Only `freebsd.yml` and `windows-arm64.yml` run on pull requests, and only for manifest paths; +nothing runs `cargo xtask ci` on PRs. Windows and Linux have startup and rendering reports +(#41, #45, #107, #142). ≈ 20–40 h (a PR CI matrix plus runtime smoke tests on three OSes). + +### G-15. Elastic Audio depth + +One warp algorithm; Pro Tools offers several per track (polyphonic, rhythmic, monophonic, +varispeed, high-quality offline) plus Elastic Pitch and warp-marker editing depth. ≈ 30–45 h. + +### G-16. Session management + +No session templates, track presets, workspace/sound browser with previews, batch rename (new in +2026.4), disk-usage and relink depth. ≈ 25–40 h. + +### G-17. Video and post + +No HEVC, DNxHD/HR or AV1 decode; no bounce with video; no external timecode/9-pin; no iXML +field-recorder conform. ≈ 30–50 h. + +### G-18. Speech-to-text + +Pro Tools transcribes dialogue to a searchable transcript (upgraded in 2026.4). We have a +settings command only. Needs an openly licensed local speech model. ≈ 30–50 h. + +### G-19. UI feel + +See [`ui-parity.md`](ui-parity.md). ≈ 60–100 h. + +### G-20. MIDI and instrument depth + +MIDI learn, a sampler (PR #108), drum machine depth, notation editing (tuplets, lyrics, +articulations, parts). ≈ 40–60 h. + +### G-21. Compressed export + +Bounce writes WAV/BWF/RF64, AIFF and FLAC; Pro Tools also writes MP3 and AAC/M4A in Bounce Mix. +≈ 10–20 h (pure-Rust encoders). + +### G-22. Avid cloud services + +Collaboration, cloud projects, Sketch, Splice, Learn, sign-in: 11 of the 30 menu leaves still +missing. Out of scope unless the owner decides otherwise. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate | +| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 0000000..2b3eadc --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,75 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware audit against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is +SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of +any MIDI-device or control-surface code; Pro Tools' from its documentation and Setup menus. + +**Hardware: ~12 % ready** (estimated). **Performance: ~35 %** (estimated; nothing benchmarked +against Pro Tools). Remaining ≈ 110–180 h for hardware, 40–70 h for performance. + +Legend: ✅ works · 🟡 partial · ❌ missing. + +## Audio interfaces + +| Feature | Pro Tools (macOS / Windows) | SoundCraft macOS | Windows | Linux | FreeBSD | Web | +|---|---|---|---|---|---|---| +| Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; no ASIO | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio | +| Choose playback device | ✅ Playback Engine | ❌ default output only | ❌ | ❌ | ❌ | ❌ | +| Choose input device | ✅ | ❌ default input only (#42) | ❌ | ❌ | ❌ | ❌ | +| Buffer size (32–2048) | ✅ | ❌ fixed 512-sample mix block | ❌ | ❌ | ❌ | ❌ | +| Duplex stream on one clock | ✅ | ❌ separate input/output streams | ❌ | ❌ | ❌ | ❌ | +| Hardware I/O mapping (I/O Setup to physical channels) | ✅ | 🟡 multichannel output in order; no per-channel map | 🟡 | 🟡 | 🟡 | ❌ | +| Low-latency monitoring | ✅ | ❌ (`options.low_latency_monitoring` is a stored flag) | ❌ | ❌ | ❌ | ❌ | +| Latency reporting and record offset compensation | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Sample rate follows the session; resample when it can't | ✅ (switches device) | 🟡 resamples (input pitch bug fixed, #28) | 🟡 | 🟡 | 🟡 | 🟡 | +| Avid HDX / HD Native DSP, Hybrid Engine DSP mode | ✅ | ❌ (out of reach: Avid hardware) | ❌ | ❌ | ❌ | ❌ | +| Disk allocation / record to disk | ✅ | ❌ recording into RAM (PR #84) | ❌ | ❌ | ❌ | ❌ | + +## MIDI + +| Feature | Pro Tools | SoundCraft (all platforms) | +|---|---|---| +| MIDI input devices (keyboards, pads) | ✅ | ❌ no MIDI device layer at all (`setup.midi_input_devices` stores names) | +| MIDI output to external gear | ✅ | ❌ | +| MIDI Thru, input filter | ✅ | ❌ (stored flags only) | +| MIDI Beat Clock out, MTC generate/chase | ✅ | ❌ | +| MIDI learn / controller mapping | ✅ (via surfaces and plug-in maps) | ❌ | +| Virtual MIDI keyboard | ✅ (MIDI Keyboard window) | 🟡 window exists, no live audition path | + +## Control surfaces + +| Feature | Pro Tools | SoundCraft | +|---|---|---| +| EUCON (S1/S3/S4/S6, Dock, Control app) | ✅ | ❌ Avid-proprietary SDK; owner decision | +| HUI | ✅ | ❌ | +| Mackie Control Universal | ✅ (via HUI emulation and personalities) | ❌ | +| Generic MIDI controller personalities | ✅ | ❌ | + +## Sync and video hardware + +| Feature | Pro Tools | SoundCraft | +|---|---|---| +| LTC / MTC chase, 9-pin machine control | ✅ (Ultimate) | ❌ | +| Word clock / video reference awareness | ✅ via interface | ❌ | +| Video output to a second display or video card | ✅ | ❌ (video window only) | +| Satellite linking of systems | ✅ | ❌ | + +## Performance + +| Feature | Pro Tools | SoundCraft | Evidence | +|---|---|---|---| +| Parallel mixing across cores | ✅ | ✅ | `soundcraft-mix` renders independent strips in parallel (rayon) | +| Plugin delay compensation | ✅ | ✅ | | +| Disk streaming of long sources | ✅ | ❌ | Whole files decoded into the `SourcePool` | +| Track count (Ultimate: 2,048 voices) | ✅ | unmeasured | No benchmark exists; add `cargo xtask bench` | +| Real-time audio thread discipline | ✅ | ✅ | No blocking/allocation in steady state, audio-thread-aware logger | +| Freeze / commit to save CPU | ✅ | 🟡 | `track.commit` renders; `track.freeze` only sets a flag (no CPU saved) | +| GPU-accelerated UI | — | ✅ (wgpu/glow via eframe) | llvmpipe X11 issue #107 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 0000000..fe832d4 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,42 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +SoundCraft is English only. There is no string catalog: every menu label, dialog and tooltip is a +literal in Rust (`crates/engine/src/cmd/*`, `crates/ui-egui/src/*`). Pro Tools ships its UI in +**8 languages** (bundle `.lproj` folders: English, German, Spanish, French, Japanese, Korean, +Simplified Chinese, Traditional Chinese). + +**Localization: ~12 %** (measured: 1 of the target's 8 UI languages, and English is the only one +complete). Remaining ≈ 70–110 h: a catalog and extraction pass (≈ 15–25 h), a font fallback for +CJK/Devanagari/Arabic (PR #141 open), RTL layout and complex-script shaping (egui has neither: +≈ 25–40 h, or ship Arabic/Hindi menus-only), then ≈ 3–5 h of agent translation per language, plus +native-speaker review (human). + +Open work: PR #143 (French and Spanish, a Language setting), PR #141 (bundled multilingual font +fallbacks), issue #67 (Ukrainian). + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Est. to full (h) | In Pro Tools | +|---|---|---|---|---|---|---|---:|---| +| English | en | all (source) | ✅ | Latin ✅ | — | **full** | 0 | ✅ | +| Simplified Chinese | zh-Hans | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ | +| Spanish | es | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ | +| Hindi | hi | 0 (0 %) | ❌ | ❌ no Devanagari font or shaping | no | none | 8–15 | ❌ | +| Arabic | ar | 0 (0 %) | ❌ | ❌ no RTL layout, no shaping | no | none | 15–25 | ❌ | +| French | fr | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ | +| Portuguese | pt | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ | +| Indonesian | id | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ | +| Japanese | ja | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ | +| German | de | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ✅ | +| Korean | ko | 0 (0 %) | ❌ | ❌ no Hangul font fallback | no | none | 6–10 | ✅ | +| Vietnamese | vi | 0 (0 %) | ❌ | 🟡 Latin with stacked diacritics, untested | no | none | 3–5 | ❌ | + +Other languages shipped: **0**. (Pro Tools also ships Traditional Chinese, zh-Hant.) + +Per-language hours exclude the shared catalog and script work above. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: English only, no catalog; the twelve key languages at 0 %; Pro Tools' 8 languages listed from its bundle | diff --git a/docs/mcp.md b/docs/mcp.md index 2949871..d3ed9a9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -22,7 +22,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo |---|---| | `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). | | `execute` | Run one command with JSON params. | -| `batch` | Run several commands in order (`keep_going` optional). | +| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. | | `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). | | `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). | | `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. | diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 94% rename from docs/parity.md rename to docs/parity-checklist.md index 5572f35..229b783 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -1,6 +1,8 @@ # Parity report -Generated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`). +> **Generated:** 2026-10-06 (last run of `cargo xtask parity`; the catalog and aliases are unchanged since) · **Target:** Avid Pro Tools Ultimate 2026.4.1 menu tree + +Generated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`). It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry. UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel). diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md new file mode 100644 index 0000000..f59b598 --- /dev/null +++ b/docs/plugin-parity.md @@ -0,0 +1,61 @@ +# Plugin parity: built-in processors and plugin hosting + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first plugin checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins +cover the rest. This doc tracks both. Our plugins have our own names and are written from +scratch (clean-room rule): Avid plugin names are not used here, only categories. + +**Built-in plugins and AudioSuite: breadth ~40 %, ready ~35 %** (80–120 h). +**Third-party hosting: breadth ~70 %, ready ~45 %** (40–60 h). Both estimated. + +## Built-in processors (measured from `crates/dsp/src/plugins`) + +| Category | Pro Tools bundles (approx., by category) | SoundCraft (ids) | Gap | +|---|---|---|---| +| EQ | 1-band and 7-band EQ, channel strip, vintage EQ models | `eq_1band`, `eq_7band`, `channel_strip` | Vintage models, linear phase, dynamic EQ; parametric UI (#139) | +| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) | +| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | +| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | +| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | +| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape | +| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction | +| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options | +| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth` | Sampler (PR #108 open), piano, organ, wavetable, content library | +| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | | + +AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in +`crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview, +handles, "clip by clip / entire selection" and multi-input modes beyond the basics; hosted +plugins can't be used offline as AudioSuite yet. + +Fidelity: **unmeasured.** Nothing compares a SoundCraft processor's frequency response, gain +reduction curve or reverb tail with the Pro Tools equivalent (G-07 in [`gaps.md`](gaps.md)). +User report #152: a saved instrument level does not reach the bounce. + +## Third-party plugin hosting + +| Feature | Pro Tools | SoundCraft | Notes | +|---|---|---|---| +| Plugin format | AAX only (Native and DSP) | CLAP, VST3, Audio Units (macOS) | Users' libraries mostly ship VST3/AU too. AAX (#109) needs Avid's SDK licence and PACE signing: owner decision, likely never | +| VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable | +| Scan, vendor menus, instrument picker | ✅ | ✅ | #23 | +| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | | +| Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | +| State saved in sessions, presets | ✅ | ✅ | | +| Create/destroy off the audio thread | ✅ | ✅ | | +| Editors: CLAP | — | 🟡 floating windows; no embedded GUI | | +| Editors: VST3 | — | 🟡 macOS only (verified with a commercial pitch editor) | Windows/Linux views missing | +| Editors: AU | — | ❌ | Cocoa view hosting missing | +| Multiple editor pages | ✅ | 🟡 | #6 | +| macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 | +| Sidechain / key input | ✅ | ❌ | All three hosts | +| ARA 2 (pitch/time editors in the timeline) | ✅ | ❌ | Menu leaves Track/Clip/Window ▸ ARA still missing | +| Crash isolation | in-process (DSP on HDX) | in-process | Out-of-process hosting would beat Pro Tools | +| Plugin delay reporting changes on the fly | ✅ | 🟡 | Aux insert latency bug #103 (PR #133) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist | diff --git a/docs/releasing.md b/docs/releasing.md index e68b9a5..c277b79 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,6 +50,7 @@ web build and are checked, the signing jobs are refused, and no release is draft | Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) | | Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `soundcraft--linux-riscv64.tar.gz` (glibc 2.39+) | `ubuntu-24.04` (cross-compiled; CLI smoke-tested under QEMU) | | AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | | Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..d8ea3f8 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,90 @@ +# SoundCraft roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha gate added; stage re-normalized to pre-alpha) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and +[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md). +Hours are Opus 5.5 agent-hours. + +## Current focus + +1. **Merge or close the open bug-fix PRs** (#123–#134, #146, #148, #75) and fix the rest of the + correctness backlog (G-04). ≈ 15–25 h. +2. **Audio interface basics** (G-01): device selection, buffer size, duplex stream, ASIO. ≈ 25–40 h. +3. **MIDI devices** (G-02): input to armed tracks and instruments, output, thru, clock. ≈ 20–35 h. +4. **macOS plugin hosting** (G-05): library-validation entitlement now (#153), then AU editors. ≈ 15–25 h. +5. **A PR CI that runs `cargo xtask ci`** on Linux, macOS and Windows (G-14). ≈ 5–10 h. + +## Alpha gate + +The core workflows a Pro Tools professional does every day, checked end to end on macOS (the +main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist +(craftrules `standards/progress-docs.md`, Stages). + +| Workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---:| +| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | +| Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | +| Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | +| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 26 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | +| Bounce mix and stems; save and reopen the session | yes (rough) | WAV/BWF/AIFF/FLAC bounce, stems, `.scraft` save/reopen with plugin state; bugs #59, #103 in bus bounce and aux latency (PRs open) | — | +| Exchange a session with Pro Tools or a picture editor | **no** | No `.ptx`, AAF or OMF import or export (#40, G-03) | 50–90 | + +**Result: pre-alpha.** Three workflows fail. Passing them is ~110–180 h, about 70 % of it +parallelizable (interfaces, MIDI and AAF are independent crates). + +## Milestones + +Status: ✅ done · 🟡 in progress · ⬜ not started. + +| # | Milestone | Status | Remaining (h) | +|---|---|---|---:| +| M0–M4 | Workspace, gates, model, I/O, DSP, mix engine, command engine, Edit/Mix UI, playback, metering, plugins, bounce | ✅ | — | +| M5 | Editing depth (tools, modes, playlists, fades, comping, clip groups) | ✅ (bugs open, G-04) | in G-04 | +| M6 | Recording, punch, loop record, monitoring | 🟡 first cut; device choice and record-to-disk missing | 40–60 | +| M7 | MIDI and notation editing | 🟡 | 50–80 | +| M8 | CLI, control channel, MCP, agent acceptance test | ✅ | 3 | +| M9 | AudioSuite breadth ✅, Elastic Audio 🟡, Beat Detective 🟡 | 🟡 | 40–60 | +| M10 | Public releases on all platforms | ✅ v0.3.0 (2026-10-08) and v0.4.0 (2026-10-10) released for every platform | — | +| M11 | Third-party plugin hosting (CLAP, VST3, AU), video, surround | 🟡 mostly done | 40–60 | +| M12 | **Hardware**: audio interfaces, MIDI devices, sync, control surfaces | ⬜ | 110–180 | +| M13 | **Interchange**: AAF/OMF import/export, disk streaming, record to disk | ⬜ | 65–115 | +| M14 | **Fidelity harness** against Pro Tools (null tests, plugin responses) | ⬜ | 20–30 | +| M15 | **Localization**: catalog, fonts/RTL, the 8 Pro Tools languages first | ⬜ (PRs #141, #143 open) | 70–110 | +| M16 | Built-in plugin depth, sidechain, ARA | ⬜ | 110–170 | +| M17 | Immersive delivery (ADM BWF, object renderer, binaural) and video depth | ⬜ | 80–130 | + +## To beta (~350–570 h, about 60–70 % parallelizable) + +Beta needs ~75 % ready for real work and no blocking gap in session interchange. + +| Step | Gaps | Est. (h) | +|---|---|---:| +| Correctness backlog and a PR CI | G-04, G-14 | 40–65 | +| Audio interfaces, MIDI devices, sync | G-01, G-02, G-11 | 60–100 | +| AAF/OMF interchange, disk streaming, record to disk | G-03, G-06 | 65–115 | +| Plugin hosting completeness on every OS, sidechain | G-05, G-13 | 35–60 | +| Fidelity harness and the fixes it finds | G-07 | 40–60 | +| Built-in plugin essentials (EQ/dynamics/reverb/delay depth, sampler) | G-08 (half) | 40–60 | +| UI feel pass: shortcuts, plugin windows, meters | G-19 (half) | 30–50 | +| Localization of the 8 Pro Tools languages (catalog, CJK fonts) | G-09 (part) | 40–60 | + +Needs a human: hardware for G-01/G-02/G-10/G-11, native-speaker review, and owner decisions on +AAX, `.ptx` and Avid cloud features. + +## After beta (to full parity, a further ~600–900 h) + +Control surfaces (G-10), immersive delivery (G-12), the rest of the built-in plugin catalogue +(G-08), Elastic Audio depth (G-15), session templates and browser (G-16), video depth (G-17), +speech-to-text (G-18), notation and instrument depth (G-20), compressed export (G-21), and the +remaining languages. + +There is no `cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity` +([`parity-checklist.md`](parity-checklist.md)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | +| 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 0000000..77d7be9 --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,252 @@ +# Target-app parity: SoundCraft vs Avid Pro Tools + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95 % for each audience; full number confirmed as an additive weighted sum) · **Target:** Avid Pro Tools Ultimate 2026.4.1 (bundle 26.4.1.179, macOS) + +The authoritative assessment of how close SoundCraft is to Pro Tools. [`ROADMAP.md`](../ROADMAP.md) +summarizes it, [`gaps.md`](gaps.md) itemizes every shortfall, and the per-dimension docs go deeper: +[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md), +[`hardware-parity.md`](hardware-parity.md), [`plugin-parity.md`](plugin-parity.md), +[`localization-parity.md`](localization-parity.md). The generated menu checklist is +[`parity-checklist.md`](parity-checklist.md). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| Menu breadth (engine + UI layer) | **482 / 512 menu leaves (94.1 %)** | **measured**: the 512-leaf catalog `crates/engine/catalog/menus.txt` against the command registry; engine alone 397 / 512 (77.5 %) from `cargo xtask parity` ([`parity-checklist.md`](parity-checklist.md)); the 85 UI-layer matches re-counted on 2026-10-10 from `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`, `AUDIOSUITE`, `MENU_WINDOWS`, Mix Window Views) by script | +| Feature breadth (menus plus everything not in a menu: plugins, hardware, formats, immersive, cloud) | **~70 %** | estimated (area table below, presence only) | +| **Ready for real work** (whole of Pro Tools) | **~40 %** | estimated: written dimension weights × values = 40.6 % ([By dimension](#by-dimension)) | +| **Mainstream practitioner** (a working tracking/mixing engineer) | **~36 %** | estimated: written area weights and discounts ([below](#mainstream-practitioner-and-essentials-user)) | +| **Essentials user** (making a song or podcast with the core features) | **~57 %** | estimated: written feature weights and discounts ([below](#mainstream-practitioner-and-essentials-user)) | +| Stage | **pre-alpha** (core-workflow gate fails) | see [Stage](#stage) | +| Remaining effort to alpha | **~110–180 Opus 5.5 agent-hours** | estimated: the failing gate rows | +| Remaining effort to beta | **~350–570 Opus 5.5 agent-hours** | estimated, calibrated (below) | +| Remaining effort to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated, calibrated (below) | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95 % | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ~40 % | ~950–1,450 h (~60–70 % parallelizable) | Hardware (interfaces, MIDI, surfaces, sync), the built-in plugin catalogue, AAF/OMF and immersive delivery, localization, fidelity against Pro Tools | +| Mainstream practitioner | ~36 % | ~380–600 h (~60 % parallelizable) | Interface recording and MIDI devices, AAF/OMF exchange, stock-plugin depth, the core-path bug backlog, interaction feel | +| Essentials user | ~57 % | ~110–200 h (~70 % parallelizable) | Launch and platform fixes, an onboarding and user guide, stock-effect panels and presets, MP3/AAC export, editing and transport bugs | + +Each audience's hours are for its own areas only, so essentials ⊂ mainstream ⊂ full. Calibration +is the same as for the full estimate ([Effort and calibration](#effort-and-calibration)): bug fixes +at 0.5–1.5 h each, features at about 200–300 tested lines per agent-hour, from the public-week PRs. +- **Mainstream sum:** recording 40–60, MIDI 50–80, editing 40–60, mixing 35–50, automation 20–30, + stock plugins 80–120, AAF/OMF 50–90, compressed export 10–20, bug backlog 25–40, interaction + feel 30–50. +- **Essentials sum:** default-device recording 10–15, transport 5–10, editing polish 15–25, effect + panels and presets 20–30, MP3/AAC export 10–20, built-in instrument 10–20, launch and platform + 15–25, onboarding and user guide 15–25, save fixes 5–10. + +The previous figure in ROADMAP.md (2026-10-07: "~65 % overall feature parity", "110–150 h to 100 %") +measured feature *presence*. This pass measures readiness, and found new evidence that pulls the +numbers down: 37 open GitHub issues from the first public week (about 25 of them correctness bugs +in features counted as done), no MIDI hardware input or output at all, no audio device or buffer +size selection, no localization, and a macOS release that cannot load signed third-party VST3s +(#153). The estimates did not drift up; the scope of what is measured widened. + +## How this was measured + +- **The target.** `/Applications/Pro Tools.app` on the owner's Mac: Info.plist reports version + 26.4.1.179 (Pro Tools Ultimate 2026.4.1). Only bundle metadata was read (version, + `CFBundleDocumentTypes` for the file types it opens, the `.lproj` folder names for its UI + languages: en, de, es, fr, ja, ko, zh-Hans, zh-Hant). Nothing inside the bundle's resources, + plug-ins or code was read or copied (clean-room rule, AGENTS.md). +- **The menu tree.** `plan/protools/menus-clean.txt` (observed black-box, local only) became the + committed 512-leaf catalog; `cargo xtask parity` and the UI layer's `engine.parity` score it. +- **Avid's own documentation.** *What's New in Pro Tools 2026.4* (Track Pin, MPEG-H renderer, + Dolby headphone personalization, speech-to-text upgrades, batch file rename) and earlier + release notes for features outside menus (Dolby Atmos renderer integration, ADM BWF, Sketch, + cloud collaboration, Splice, ARA, HEAT, control surfaces, I/O setup). +- **The code on origin/main (3bc2d92, 2026-10-10).** Read and counted with grep: ~70,900 lines of + Rust; 524 `#[test]` functions plus proptest suites; ~350 macro-registered engine command ids; + 26 built-in processors and 2 instruments in `crates/dsp/src/plugins`; audio I/O in + `crates/audio-io`; device I/O in `crates/playback` (cpal default output and default input only); + no MIDI device crate (no `midir`/CoreMIDI anywhere); no string catalog. +- **Users.** The 37 open and 21 closed GitHub issues, and the 42 merged and 29 open pull + requests up to 2026-10-10, are the only field evidence. Nothing has been benchmarked against + Pro Tools. +- **Not done:** running Pro Tools side by side on the same material (null tests of mixes, plugin + responses, fades, elastic audio). That is gap G-01 in [`gaps.md`](gaps.md). + +## By dimension + +Weights are how much each dimension decides whether a Pro Tools user (music production, +post-production, tracking) can switch. "Ready" is depth, correctness and fidelity, not presence. + +| Dimension | Weight | Ready | Kind | Remaining (h) | Doc | Evidence | +|---|---:|---:|---|---:|---|---| +| Features | 45 % | **~45 %** | estimated | 520–810 | [area table](#feature-areas) | Menu breadth 94 % measured, but depth is thin: see the area table | +| UI/UX fidelity | 10 % | **~55 %** | estimated | 60–100 | [ui-parity.md](ui-parity.md) | Edit/Mix windows, every menu, floating windows; open UX bugs #3 #5 #6 #7 #140 #144; shortcuts only recently right on Windows/Linux (#15, #41); generic plugin UIs | +| File formats | 10 % | **~40 %** | estimated | 70–120 | [file-format-parity.md](file-format-parity.md) | WAV/BWF/RF64/AIFF/FLAC read+write, compressed formats read; no `.ptx`, AAF, OMF, ADM BWF, MP3/AAC export, video export | +| Hardware | 10 % | **~12 %** | estimated | 110–180 | [hardware-parity.md](hardware-parity.md) | Default output and default input device only, fixed 512-sample block, no ASIO, no MIDI devices, no control surfaces, no sync | +| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), X11 llvmpipe failure (#107) | +| Performance | 5 % | **~35 %** | estimated (unmeasured) | 40–70 | [hardware-parity.md](hardware-parity.md) | Parallel strip rendering and PDC exist; whole files decoded into RAM, recording into RAM (#84 open), no track-count or latency benchmarks | +| Ecosystem / plugins | 4 % | **~30 %** | estimated | in Features + Hardware | [plugin-parity.md](plugin-parity.md) | CLAP/VST3/AU hosting; no AAX (Pro Tools' only format), no ARA, no sidechain, no control-surface ecosystem, no content library | +| Platforms | 3 % | **~65 %** | estimated | 20–40 | [ROADMAP.md](../ROADMAP.md) | Builds for macOS, Windows x64/x86/arm64, Linux, FreeBSD, Web (Pro Tools: macOS, Windows), but only macOS is exercised at runtime; Web lacks file pickers (#63, #64) | +| Localization | 2 % | **~12 %** | measured (catalog count) | 70–110 | [localization-parity.md](localization-parity.md) | English only, no string catalog; Pro Tools ships 8 UI languages; FR/ES PR #143 open | +| AI features | 1 % | **~5 %** | estimated | 30–50 | [gaps.md](gaps.md) | Pro Tools has speech-to-text transcription (upgraded in 2026.4); we have only an audio-to-MIDI pitch tracker | +| **Weighted** | 100 % | **~40 %** | | **~950–1,450** (overlaps removed) | | | + +Agent control is not weighted (Pro Tools has only its scripting SDK); SoundCraft is ahead there: +every command is reachable from the CLI, the JSON control channel and MCP. + +## Mainstream practitioner and essentials user + +Both use the method in craftrules `standards/progress-docs.md`. Each multiplies a weighted depth +average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and +the evidence there; issue numbers are `storytold/soundcraft` GitHub issues. + +### Mainstream practitioner: ~36 % + +The typical Pro Tools professional is a music or post engineer who tracks through an audio +interface, overdubs MIDI, edits, mixes with the stock plugins and bounces every week. This number +leaves out third-party plugin hosting, Avid cloud services and AI, control surfaces and sync, +immersive delivery, video, notation, and languages other than English. + +| Area (weekly use) | Weight | Depth | +|---|---:|---:| +| Recording and monitoring through an interface | 20 % | 40 % | +| Editing (trim, fades, comping, Slip/Grid, nudge) | 22 % | 65 % | +| Mixing and routing (faders, sends, busses, inserts, PDC) | 18 % | 60 % | +| Automation | 10 % | 55 % | +| Built-in plugins and AudioSuite | 12 % | 35 % | +| MIDI recording, editing, instruments | 10 % | 30 % | +| Bounce, save and reopen | 8 % | 60 % | +| **Weighted depth** | 100 % | **50.6 %** | + +| Discount | Factor | Evidence | +|---|---:|---| +| Interaction fidelity | ×0.90 | Shortcut collisions and dead shortcuts (#140 Heal, #144 Mix/Edit vs zoom, #7 Export); Multitool handles don't adjust audio (#5); generic plugin parameter panels (#138, #139); clip deletion inconsistencies (#3, #4) | +| Stability on real machines | ×0.92 | 26 open core-path bugs, e.g. bounce and latency (#59, #103), playback speed (#101), session saves (#86, #91), plus a launch failure (#142). Never-crash rules and property tests hold up: no crash reports on macOS since #16 | +| File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower | +| **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | | + +This comes out *below* the full number (~40 %), which the standard says is unusual. The +mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the +alpha-gate failures. The full number gets partial credit from areas this user doesn't weight: +editing breadth, five platforms built, agent control. + +**User evidence** (GitHub, 2026-10-10; all 69 issues are from 28 outside reporters, none from +maintainers): +- **Praise:** 0 threads, and no "switched from Pro Tools" reports. +- **Open issues:** of 48 open, 26 are core-path bugs (editing, bounce, playback, sessions, + shortcuts, plugin loading, recording monitoring), 7 are niche-path bugs (web build, CLI/MCP, + Linux fonts and llvmpipe), and 15 are requests. +- **Requests:** several ask for other DAWs' feel: Ableton shortcuts and layout (#71, #72), an + FL/Ableton port (#38), Audition-style file editing (#81, #82). AAX (#109), VST2/LV2 (#30), + Ukrainian (#67). +- **Closed:** 21. +- **Contributions:** 12 outside contributors have merged PRs in the first week. That's + engagement, not yet use on real projects. + +### Essentials user: ~57 % + +A user who needs only the essentials: makes a song, demo or podcast, records through the computer's default +microphone or interface, uses default settings, and exports a file to share. + +| Core feature | Weight | Depth | Notes | +|---|---:|---:|---| +| New session, open a session, demo | 10 % | 80 % | | +| Import audio (drag and drop, WAV/MP3/AAC/FLAC) | 10 % | 80 % | Reads every common format | +| Record from the default device | 12 % | 65 % | Works on the default input; forced monitoring can feed back (#42) | +| Play, stop, loop, scroll, zoom | 10 % | 75 % | Half-speed after Stop (#101) | +| Basic editing: select, cut/copy/paste, split, trim, move, fades | 18 % | 70 % | #3, #5 | +| Undo/redo | 5 % | 90 % | Every command undoable | +| Volume, pan, mute, solo | 10 % | 85 % | | +| Add a stock effect (EQ, compressor, reverb) and pick a preset | 10 % | 60 % | Generic panels, few presets | +| Draw MIDI notes for a built-in synth | 5 % | 50 % | Two built-in instruments | +| Save and reopen | 5 % | 80 % | Web build can drop unsaved edits (#136) | +| Export / bounce a file to share | 5 % | 60 % | WAV/AIFF/FLAC only, no MP3/AAC | +| **Weighted depth** | 100 % | **72.4 %** | | + +| Discount | Factor | Evidence | +|---|---:|---| +| Launch and stability | ×0.90 | Doesn't launch for one user (#142), X11 llvmpipe failure (#107), Linux symbols as boxes (#45), Windows shortcuts (#41); macOS is solid | +| Discoverability and UI clarity | ×0.90 | Pro Tools' dense layout with no onboarding or user guide; shortcut collisions; plugin windows are bare sliders | +| Opening files people send them | ×0.97 | These users get WAV/MP3, which work; only Pro Tools sessions fail | +| **Result** | 72.4 × 0.90 × 0.90 × 0.97 = **56.9 % ≈ 57 %** | | + +## Feature areas + +Weights by what Pro Tools users actually spend time on. **Breadth** = the feature exists; +**Ready** = it works deeply and correctly enough for paid work. + +| Area | Weight | Breadth | Ready | Remaining (h) | What's there / what's missing | +|---|---:|---:|---:|---:|---| +| Recording and monitoring | 12 % | 75 % | **40 %** | 40–60 | Punch in/out, QuickPunch-style punch on the fly, pre/post-roll, loop record into playlists, input monitoring through the strip, autosave/recovery. Missing: input/output device choice, buffer size, duplex low-latency path, record straight to disk (#84 open), per-channel hardware input routing, TrackPunch/destructive punch beyond flags, monitoring opt-out (#42) | +| Editing | 15 % | 92 % | **65 %** | 40–60 | Slip/Shuffle/Spot/Grid, every Edit menu command, fades/crossfades, playlists and comping, clip groups, nudge, Tab to transient, separate/heal/consolidate/strip silence. Open bugs: #3, #5 (Multitool handles), #140 (Heal shortcut), #86, #91, #92; no batch fade presets, no field-recorder workflows | +| Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 | +| Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 | +| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 26 processors + 2 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) | +| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) | +| MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (Sampler PR #108 open), bug #98 | +| Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) | +| Elastic Audio, TCE, Beat Detective, pitch | 5 % | 70 % | **35 %** | 40–60 | Pitch-preserving warp on Elastic tracks, TCE, conform to tempo, Beat Detective and Identify Beat, audio-to-MIDI. Missing: per-algorithm choice (polyphonic/rhythmic/monophonic/varispeed/X-Form), warp markers UI depth, Elastic Pitch, quality vs Pro Tools unmeasured | +| Surround and immersive | 5 % | 55 % | **35 %** | 50–80 | Formats stereo → 9.1.6 and Ambisonics, surround panner, fold-down, multichannel bounce, object/bed routing. Missing: Dolby Atmos renderer integration and ADM BWF, MPEG-H (new in 2026.4), binaural/headphone monitoring, surround automation; bug #89 | +| Video and post | 5 % | 60 % | **40 %** | 40–60 | Video track, H.264/ProRes/MJPEG decoders, sync offset, relinking, timecode rulers. Missing: HEVC/DNx/AV1, video bounce/export, timecode sync to external machines, speech-to-text transcript, field-recorder/iXML conform | +| Session, file management, interchange | 10 % | 55 % | **35 %** | 60–100 | `.scraft` + Audio Files, Save Copy In, Import Session Data, clip groups, session text export, audio-health report. Missing: `.ptx` read/write, AAF/OMF (#40), session templates, track presets, workspace/Soundbase browser, disk streaming of long sources, batch rename; bugs #86, #91, #136 | +| Collaboration, cloud, Sketch, Splice | 2 % | 0 % | **0 %** | not estimated | Avid services; out of scope unless the owner decides otherwise | +| Agent control (CLI, control channel, MCP) | 1 % | 100 % | **100 %** | 3 | Ahead of Pro Tools' scripting SDK; one open bug (#145) | +| **Weighted** | 100 % | **~70 %** | **~45 %** | **~520–810** | | + +## Stage + +**Pre-alpha.** Readiness (~40 %) is at the alpha bar, but the core-workflow gate +([`roadmap.md`](roadmap.md#alpha-gate)) fails three of six Pro Tools workflows on macOS, the main +platform: + +- Tracking from a chosen interface at a chosen buffer size: **no**. Only the default devices are + used, the mix runs in fixed 512-sample blocks, input and output are separate streams, and + takes are held in RAM. +- MIDI recording: **no**. There is no MIDI device layer. +- Session interchange with Pro Tools or picture editors: **no**. No AAF/OMF/`.ptx`. + +Editing, mixing with plugins, bouncing, and saving and reopening pass (with known bugs). + +**Distance to alpha: ~5 points of readiness and ~110–180 agent-hours** (G-01, G-02, G-03, part +of G-06, and the #153 entitlement). **Distance to beta: ~35 points and ~350–570 agent-hours** +(beta also needs ~75 % ready). The beta plan is in [`roadmap.md`](roadmap.md). + +## Effort and calibration + +Hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, including +tests and verification. + +**Calibrated against this repo's history** (`git log`, `gh pr list`): + +- The 10-05 bulk commits (`first commit` through `Video track, Audio Unit hosting…`, ~70k lines) + carry batch-commit times, not authoring times, so they don't calibrate. +- The public week (2026-10-07 → 10-10, 42 merged PRs) does. Focused bug fixes with a regression + test, +20 to +120 lines (#116–#122, #25, #28, #37, #61), took **~0.5–1.5 h** each. Small + features of +200 to +1,000 lines (#32 light theme +516, #11 log file +981, #8 credits +666, #23 + plugin menus +259, #76 clip-gain fader +198) took **~2–5 h**: about **200–300 tested lines per + agent-hour**. +- Areas were sized by the code they need at that rate (for example, MIDI device I/O plus MIDI + recording ≈ 3–5k lines ≈ 12–25 h), plus a 1.3–1.5× tail factor for verification against Pro + Tools behaviour, as FilmCraft observed for its long tail. + +**Parallelism.** About 60–70 % of the work parallelizes across 4–5 agents by crate (dsp plugins, +hardware/playback, file interchange, UI, localization). The audio-thread and hardware work +serializes more, because it needs hands-on testing. + +**Needs a human:** +- Owner decisions: AAX (requires Avid's SDK licence and PACE signing; probably never), `.ptx` + reverse engineering (clean-room implications), Avid cloud features (out of scope by default). +- Hardware we don't have in CI: multichannel interfaces, ASIO drivers, control surfaces (HUI/MCU, + EUCON is Avid-proprietary), MIDI keyboards, sync generators. +- Native-speaker review for every language beyond English. +- Ears: a mix engineer's A/B of plugins, fades and elastic audio. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95 % for full (950–1,450 h), mainstream (380–600 h) and essentials (110–200 h). The full number is already the additive weighted sum over the written dimension table (40.6 %); method unchanged | +| 2026-10-10 | minor | Added the mainstream-practitioner (~36 %) and essentials-user (~57 %) numbers with written weights, discounts and user evidence. Checked the full ~40 %: it is the written dimension weights × values (40.6 %), so it doesn't move | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; alpha distance ~110–180 h | +| 2026-10-10 | major | Full re-measure against Pro Tools Ultimate 2026.4.1: two numbers (breadth ~70 %, ready ~40 %), dimension and area tables with hours, stage alpha; hours re-calibrated from PR history (110–150 h → 950–1,450 h to full parity: the old figure counted presence only) | +| 2026-10-07 | major | Area table and "~65 % overall, 110–150 h" estimate (then in ROADMAP.md) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 0000000..80ff25b --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,67 @@ +# UI parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, +windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command +registry (shortcuts), `crates/ui-egui/src` (windows, gestures) and user issues; compared with +reference screenshots kept locally under `plan/protools/screenshots/` (16, never committed). + +**UI/UX fidelity: ~55 % ready** (estimated). Remaining ≈ 60–100 h. + +Legend: ✅ matches · 🟡 partial · ❌ missing. + +## Tools and modes + +| Feature | Status | Notes | +|---|---|---| +| Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | +| Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | +| Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | +| Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | +| Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | | +| Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured | + +## Keyboard and mouse + +| Feature | Status | Notes | +|---|---|---| +| Default shortcuts | 🟡 146 bound (measured: distinct `Some("…")` shortcuts in the registry) | Pro Tools' default set is several hundred incl. numeric keypad and focus keys. Collisions: Mix/Edit toggle vs zoom (#144), Heal (#140), Export (#7) | +| Ctrl as Cmd on Windows/Linux | ✅ since #15, #41 still open in some builds | #62 shows Ctrl in labels | +| Numeric keypad transport and memory-location recall | 🟡 | Classic / Transport / Shuttle modes not all present | +| Modifier gestures (Alt for all tracks, Shift-Alt for selected, Cmd-click fine faders, Ctrl-click defaults) | 🟡 | Main ones on faders and pan; not across every control | +| Scroll/zoom: wheel, trackpad pinch | 🟡 | Anchored trackpad zoom in open PRs #112, #137 | +| Drag and drop: Clip List → tracks, files → tracks | ✅ macOS/Windows; 🟡 Wayland (PR #150) | | +| Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4) | + +## Windows and panels + +| Window | Status | Notes | +|---|---|---| +| Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) | +| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Delay Compensation view and HEAT missing | +| MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow | +| Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | | +| Workspace / sound browser with previews | ❌ | G-16 | +| I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) | +| Preferences | 🟡 | Many settings stored, fewer honoured | +| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 | +| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) | +| Command palette (Search) | ✅ | Over every command | + +## Look and meters + +| Feature | Status | Notes | +|---|---|---| +| Dark theme close to Pro Tools' | ✅ | Light theme (#32) and live system appearance (#85) beyond Pro Tools | +| Interface scaling | 🟡 | Slider in PR #147 | +| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial | +| Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | | +| Fader resolution, fine adjust, numeric entry | 🟡 | | +| macOS system menu bar | ✅ | #10 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 65e9a60..a949dba 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package SoundCraft for Linux ( is x86_64 or aarch64): +# Build and package SoundCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -13,6 +13,10 @@ # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. +# +# Cross-compiling (CI's linux-riscv64 job, tar.gz only): set CROSS_ARCH (riscv64), CROSS_TARGET +# (the Rust target), CROSS_COMPILE (the binutils prefix, for strip) and optionally EMULATOR +# (e.g. qemu-riscv64) to smoke-test the CLI. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -25,15 +29,16 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,17p' "$0"; exit 0 ;; + -h | --help) sed -n '2,19p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; + riscv64) DEB_ARCH=riscv64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; esac export SOUNDCRAFT_MAINTAINER="${SOUNDCRAFT_MAINTAINER:-SoundCraft maintainers }" @@ -42,9 +47,13 @@ BASENAME="soundcraft-$VERSION-linux-$ARCH" echo "==> SoundCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/soundcraft" "$STAGE/usr/bin/soundcraft" install -Dm755 "$BIN/soundcraft-cli" "$STAGE/usr/bin/soundcraft-cli" -strip "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +144,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/soundcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/soundcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/soundcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md index 5bbc169..0cacbb7 100644 --- a/packaging/macos/dmg/README.md +++ b/packaging/macos/dmg/README.md @@ -2,8 +2,8 @@ What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the `Applications` link side by side. `package.sh` copies these files into the image; nothing here is -generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no -Finder scripting on CI, nothing extra installed in the signing job). +generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder +scripting on CI, nothing extra installed in the signing job). | File | What | |---|---| diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index a898e3d..8208aac 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -109,6 +109,10 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" +# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing: +# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle. +xattr -cr "$APP" + # Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). # Today the only nested code is the main executable; frameworks/helpers would be signed here too. sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft" @@ -133,15 +137,52 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (#176). `create` +# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so +# it gets a few tries. The volume name has no version: .DS_Store finds the background through an +# alias that includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "SoundCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app users get must still pass --strict: inside the finished image and after a ditto copy out +# of it, so a valid container signature can't hide a damaged app (or a makehybrid-style regression). +MOUNT="$WORK/dmg-check" +COPY="$WORK/dmg-copy" +rm -rf "$MOUNT" "$COPY" +mkdir -p "$MOUNT" "$COPY" +hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null +dmg_ok=1 +codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0 +if [ "$dmg_ok" = 1 ]; then + { ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" && + codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0 +fi +if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then + spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0 +fi +[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true +hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0 +rm -rf "$COPY" +if [ "$dmg_ok" != 1 ]; then + echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2 + exit 1 +fi if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9ccb8fe..8727c05 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,7 +35,7 @@ const LAYERS: &[(&str, u8)] = &[ ]; /// Standalone format crates: no workspace dependencies at all (publishable on their own). const STANDALONE: &[&str] = &["time", "audio-io", "midi", "video", "dsp"]; -const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; +const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"]; fn layers() -> Result<(), String> { @@ -113,7 +113,7 @@ fn assets() -> Result<(), String> { } fn parity() -> Result<(), String> { - cargo(&["run", "-q", "-p", "soundcraft-cli", "--", "parity", "--write", "docs/parity.md"]) + cargo(&["run", "-q", "-p", "soundcraft-cli", "--", "parity", "--write", "docs/parity-checklist.md"]) } /// Crates that must compile for the web.