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 0fec370..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,429 +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 - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI (which links no ALSA) is smoke-tested under QEMU. Plugin hosting (CLAP, VST3) is - # pure-Rust bindings plus dlopen, so it builds here unchanged; cpal uses ALSA (no JACK). - runs-on: ubuntu-24.04 - # 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 - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - # The riscv64 twins of the linux job's -dev packages; libasound2-dev is cpal's ALSA backend. - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - pkg-config file desktop-file-utils appstream \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 - - name: Build and package (tar.gz) - env: - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - name: Check the package - run: | - file target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'RISC-V' - # cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml). - riscv64-linux-gnu-readelf -d target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'libasound' - tar -tzf "dist/release/soundcraft-$SOUNDCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E 'bin/soundcraft$' - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - 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, linux-riscv64, 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/Cargo.lock b/Cargo.lock index fcd637d..480e113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3418,6 +3418,8 @@ dependencies = [ "egui", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "rfd", "serde_json", "soundcraft-engine", diff --git a/README.md b/README.md index 33d08f1..b297d75 100644 --- a/README.md +++ b/README.md @@ -277,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 | diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 46271c9..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,6 +295,39 @@ 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 @@ -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()); @@ -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/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 1ac7b17..a38e66c 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -234,9 +234,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/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/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/file.rs b/crates/engine/src/cmd/file.rs index d58af41..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,7 +448,6 @@ mod tests { assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); let _ = std::fs::remove_dir_all(&dir); } - fn tone_engine() -> (Engine, u64) { let mut e = Engine::default(); e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); @@ -428,4 +485,140 @@ mod tests { assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); let _ = std::fs::remove_dir_all(&dir); } + + fn midi_engine() -> Engine { + use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + let mut s = Session::default(); + let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys")); + let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums")); + let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room")); + let mut keys_seq = Sequence::default(); + keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 }); + let mut drum_seq = Sequence::default(); + drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 }); + drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 }); + let a = s.new_clip_id(); + let b = s.new_clip_id(); + crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq)); + crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq)); + Engine::new(s) + } + + fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> { + let smf = soundcraft_midi::read_smf(bytes).unwrap(); + let mut out = Vec::new(); + for t in &smf.tracks { + for c in &t.sequence.ctrls { + if let soundcraft_midi::CtrlKind::Cc(n) = c.kind { + out.push((t.name.clone(), n, c.value)); + } + } + } + out + } + + #[test] + fn midi_export_keeps_controllers_and_honours_tracks() { + let mut e = midi_engine(); + let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let all = dir.join("all.mid"); + e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&all).unwrap()); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}"); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}"); + assert!(ctrls.iter().all(|(n, _, _)| n != "Room")); + + let keys = dir.join("keys.mid"); + e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&keys).unwrap()); + assert_eq!(ctrls.len(), 1, "{ctrls:?}"); + assert_eq!(ctrls[0].0, "Keys"); + + let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err(); + assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn import_surround_creates_a_matching_track() { + let channels: Vec> = (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 cb3fc18..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) } @@ -471,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)> { @@ -480,10 +540,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); let mut written = 0; - for src in &mut s.sources { + let shared_paths: Vec = + s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); + for (i, src) in s.sources.iter_mut().enumerate() { let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); - if exists_rel && !src.unsaved { + let shared = shared_paths.get(i).copied().unwrap_or(false); + if exists_rel && !src.unsaved && !shared { continue; } let Some(audio) = s.pool.get(src.id) else { continue }; @@ -499,7 +562,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess } } else { let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path) { + while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) { target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); k += 1; if k > 999 { @@ -622,6 +685,28 @@ mod save_tests { assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn save_gives_sources_that_share_a_path_their_own_files() { + let mut s = Session::default(); + let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] }; + add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav); + add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav); + let mut e = Engine::new(s); + let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Shared.scraft"); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect(); + assert_eq!(paths.len(), 2); + assert_ne!(paths[0], paths[1], "{paths:?}"); + let a = std::fs::read(dir.join(&paths[0])).unwrap(); + let b = std::fs::read(dir.join(&paths[1])).unwrap(); + assert_ne!(a, b); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] 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 ef6b8ef..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); @@ -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/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 83f5135..c288324 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -237,6 +237,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, @@ -264,6 +266,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, } @@ -297,6 +300,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(), @@ -319,6 +323,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(), } } @@ -346,6 +351,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) => { @@ -384,6 +392,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 { @@ -529,6 +539,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() { @@ -742,7 +758,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); @@ -766,7 +788,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); } @@ -822,7 +844,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); @@ -996,6 +1023,16 @@ mod tests { assert!(!app.engine.is_dirty()); } + #[test] + fn clear_all_clip_indicators_clears_every_meter() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.meters.entry(kick).or_default().clip = true; + app.main_meter.clip = true; + app.run("track.clear_clip_indicators", json!({})).unwrap(); + assert!(!app.meters[&kick].clip && !app.main_meter.clip); + } + #[test] fn automation_first_pass_after_start_is_due_without_overflow() { // `last_write_at` starts (and resets) at `i64::MIN`: the previous @@ -1043,4 +1080,43 @@ mod tests { assert!(!maximizes(1600.0, 1)); assert!(!maximizes(2400.0, 2)); } + + #[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/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 0a6b84e..d977825 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -22,11 +22,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { 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.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) - .map(|p| p.meters()); + 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; @@ -414,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/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..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; } 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/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/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index fc7fdc4..5d04d47 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -1,7 +1,7 @@ use egui::os::OperatingSystem; use egui::{Key, Modifiers}; use serde_json::json; -use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts}; fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); @@ -24,3 +24,23 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}"); } } + +#[test] +fn cmd_equals_toggles_mix_and_edit_without_zooming() { + let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(os); + ctx.set_zoom_factor(1.25); + press(&ctx, &mut app, Key::Equals, cmd); + assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}"); + press(&ctx, &mut app, Key::Equals, cmd); + press(&ctx, &mut app, Key::Num0, cmd); + press(&ctx, &mut app, Key::Minus, cmd); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}"); + assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}"); + } +} 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/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"