From eaa867da82874672849801a0c5c2bbbb54d87638 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 15 Aug 2026 14:50:37 +0900 Subject: [PATCH 1/5] feat(au): complete audio and MIDI support --- .github/workflows/build.yml | 33 +- .github/workflows/codeql.yml | 52 ++ .github/workflows/docs.yml | 41 +- .github/workflows/test.yml | 73 ++- Cargo.lock | 6 +- README.md | 26 +- bundler.toml | 9 + nih_plug_iced/Cargo.toml | 10 +- nih_plug_iced/src/editor.rs | 9 +- plugins/examples/sine/Cargo.toml | 4 + plugins/examples/sine/src/lib.rs | 15 + scripts/au_midi_smoke.sh | 75 +++ scripts/au_midi_smoke/Info.plist | 18 + scripts/au_midi_smoke/MiniHost.c | 432 ++++++++++++++ src/midi.rs | 27 + src/wrapper/au.rs | 1 + src/wrapper/au/context.rs | 41 +- src/wrapper/au/midi.rs | 961 ++++++++++++++++++++++++++++++ src/wrapper/au/wrapper.rs | 981 ++++++++++++++++++++++++------- 19 files changed, 2537 insertions(+), 277 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100755 scripts/au_midi_smoke.sh create mode 100644 scripts/au_midi_smoke/Info.plist create mode 100644 scripts/au_midi_smoke/MiniHost.c create mode 100644 src/wrapper/au/midi.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 872d2c1a4..ad9b8aabb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,11 +11,27 @@ on: - master workflow_dispatch: +# Packaging a commit that has already been superseded wastes a full macOS +# universal build. Tags get their own group, so release builds are never +# cancelled by a subsequent branch push. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + defaults: run: # This otherwise gets run under dash which does not support brace expansion shell: bash +env: + # See the note in `test.yml`: `portable_simd` is unstable and current + # nightlies no longer resolve `std::simd::{LaneCount, SupportedLaneCount}`. + # Keep this in sync with `test.yml`. + NIGHTLY_TOOLCHAIN: nightly-2025-01-01 + jobs: # We'll only package the plugins with an entry in bundler.toml package: @@ -28,7 +44,7 @@ jobs: name: Package plugin binaries runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Fetch all git history run: git fetch --force --prune --tags --unshallow @@ -38,7 +54,7 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2-dev libgl-dev libjack-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxcursor-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 # FIXME: Caching `target/` causes the Windows runner to blow up after some time if: startsWith(matrix.os, 'windows') with: @@ -46,8 +62,9 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - key: ${{ matrix.name }}-${{ matrix.cross-target }} - - uses: actions/cache@v4 + key: package-${{ matrix.name }}-${{ matrix.cross-target }}-${{ env.NIGHTLY_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: package-${{ matrix.name }}-${{ matrix.cross-target }}-${{ env.NIGHTLY_TOOLCHAIN }}- + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 if: "!startsWith(matrix.os, 'windows')" with: path: | @@ -55,12 +72,14 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ matrix.name }}-${{ matrix.cross-target }} + key: package-${{ matrix.name }}-${{ matrix.cross-target }}-${{ env.NIGHTLY_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: package-${{ matrix.name }}-${{ matrix.cross-target }}-${{ env.NIGHTLY_TOOLCHAIN }}- - name: Set up Rust toolchain # Needed for SIMD - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} # The macOS AArch64 build is done from an x86_64 macOS CI runner, so # it needs to be cross compiled targets: ${{ matrix.cross-target }} @@ -98,7 +117,7 @@ jobs: mv target/bundled/* "$ARCHIVE_NAME/$ARCHIVE_NAME" - name: Add an OS-specific readme file with installation instructions run: cp ".github/workflows/readme-${{ runner.os }}.txt" "$ARCHIVE_NAME/$ARCHIVE_NAME/README.txt" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ env.ARCHIVE_NAME }} path: ${{ env.ARCHIVE_NAME }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..94f9552a1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,52 @@ +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-22.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + language: + - actions + - rust + permissions: + contents: read + packages: read + security-events: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # Rust and GitHub Actions support build-mode none. This covers the full + # tracked source and workflow tree without duplicating the expensive + # build/test jobs. + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-and-quality + + - name: Analyze + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4af0e09b2..31c20f9f0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,17 +5,28 @@ on: branches: - master +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + defaults: run: # This otherwise gets run under dash which does not support brace expansion shell: bash +env: + # See the note in `test.yml`. Keep this in sync with the other workflows. + NIGHTLY_TOOLCHAIN: nightly-2025-01-01 + jobs: docs: name: Generate and upload docs runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Needed for git-describe to do anything useful - name: Fetch all git history run: git fetch --force --prune --tags --unshallow @@ -25,29 +36,25 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2-dev libgl-dev libjack-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxcursor-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev - - uses: actions/cache@v4 - # FIXME: Caching `target/` causes the Windows runner to blow up after some time - if: startsWith(matrix.os, 'windows') - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ matrix.name }}-${{ matrix.cross-target }} - - uses: actions/cache@v4 - if: "!startsWith(matrix.os, 'windows')" + # This job has no matrix, but both cache steps were keyed on + # `${{ matrix.name }}-${{ matrix.cross-target }}` and gated on + # `matrix.os`, so the key was always the literal `-`. + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ matrix.name }}-${{ matrix.cross-target }} + key: docs-ubuntu-22.04-${{ env.NIGHTLY_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: docs-ubuntu-22.04-${{ env.NIGHTLY_TOOLCHAIN }}- - name: Set up Rust toolchain # Nightly is needed to document the SIMD feature and for the # `doc_auto_cfg` feature - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} - name: Generate documentation for all targets # Don't use --all-features here as that will enable a whole bunch of # conflicting iced features. We also don't want to use `--workspace` @@ -68,7 +75,11 @@ jobs: EOF - name: Deploy to GitHub Pages - uses: JamesIves/github-pages-deploy-action@v4.3.0 + # The deploy target and its SSH key belong to upstream. On a fork this + # step can only fail — the secret is not there — so building the docs + # stays useful as a check while publishing is skipped. + if: github.repository == 'robbert-vdh/nih-plug' + uses: JamesIves/github-pages-deploy-action@360c8e75d0ee81732d0a5675c71e51b569df2ee8 # v4.3.0 with: branch: gh-pages folder: target/doc diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8055eb235..2b04e0e9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,11 +6,29 @@ on: branches: - master +# A push that supersedes an in-flight run makes that run's result worthless, and +# the macOS jobs here are slow enough that queued-but-stale runs delay the ones +# somebody is actually waiting on. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + defaults: run: # This otherwise gets run under dash which does not support brace expansion shell: bash +env: + # `simd` requires a nightly compiler, and `portable_simd` is not a stable API: + # `std::simd::{LaneCount, SupportedLaneCount}` no longer resolve on current + # nightlies, which broke every job in this workflow. Pin the toolchain so the + # build is reproducible, and bump this deliberately once the SIMD adapters in + # `src/buffer/` have been ported to the newer API. + NIGHTLY_TOOLCHAIN: nightly-2025-01-01 + jobs: test: strategy: @@ -19,7 +37,7 @@ jobs: name: Build and test all components runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Needed for git-describe to do anything useful - name: Fetch all git history run: git fetch --force --prune --tags --unshallow @@ -30,7 +48,11 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2-dev libgl-dev libjack-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxcursor-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev - - uses: actions/cache@v4 + # The cache key used to be `${{ matrix.name }}-${{ matrix.cross-target }}`, + # neither of which exists in this workflow's matrix, so all three runners + # shared the single key `-` and kept overwriting each other's caches with + # artifacts for the wrong platform. + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 # FIXME: Caching `target/` causes the Windows runner to blow up after some time if: startsWith(matrix.os, 'windows') with: @@ -38,8 +60,9 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - key: ${{ matrix.name }}-${{ matrix.cross-target }} - - uses: actions/cache@v4 + key: test-${{ matrix.os }}-${{ env.NIGHTLY_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: test-${{ matrix.os }}-${{ env.NIGHTLY_TOOLCHAIN }}- + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 if: "!startsWith(matrix.os, 'windows')" with: path: | @@ -47,23 +70,50 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ matrix.name }}-${{ matrix.cross-target }} + key: test-${{ matrix.os }}-${{ env.NIGHTLY_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: test-${{ matrix.os }}-${{ env.NIGHTLY_TOOLCHAIN }}- - name: Set up Rust toolchain # Needed for SIMD - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} - name: Run the tests # Don't use --all-features as that will enable a whole bunch of # conflicting iced features. `--locked` ensures that the lockfile is up # to date. We only really need this in one of the builds. run: cargo test --locked --workspace --features "simd,standalone,zstd" + # The Audio Unit wrapper is macOS-only, so the workspace run above never + # compiles it. Without this step the AU code — the part of this fork that + # upstream does not have — has no CI coverage at all. + - name: Test the Audio Unit wrapper + if: startsWith(matrix.os, 'macos') + run: cargo test --locked --features "au,vst3,zstd" + + # Unit tests cover the selector and packet conversion edge cases. This + # also loads the generated aumu through AudioComponent, sends MIDI into + # it, renders non-silent audio, and receives the MIDI output callback. + - name: Run the Audio Unit MIDI host smoke test + if: startsWith(matrix.os, 'macos') + run: scripts/au_midi_smoke.sh + + # `nih_debug_assert!` becomes a panicking `debug_assert!` under `cfg(test)`, + # so the release behaviour of contract-violating paths is only reachable + # from `#[cfg(not(debug_assertions))]` tests. A different set of tests runs + # here than in the debug run above; that asymmetry is intentional. + - name: Run the release-only tests + # These tests are platform-independent. Running them on every matrix + # member tripled their cost without increasing coverage. + if: startsWith(matrix.os, 'ubuntu') + run: cargo test --locked --release --lib --features "standalone,zstd" + # This makes sure that NIH-plug can be compiled without VST3 support build-without-vst3: name: Build NIH-plug without VST3 support runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Fetch all git history run: git fetch --force --prune --tags --unshallow @@ -72,17 +122,20 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2-dev libgl-dev libjack-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxcursor-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: build-without-vst3-ubuntu + key: build-without-vst3-ubuntu-${{ env.NIGHTLY_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: build-without-vst3-ubuntu-${{ env.NIGHTLY_TOOLCHAIN }}- - name: Set up Rust toolchain # Needed for SIMD - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} - name: Run the tests run: cargo build --no-default-features diff --git a/Cargo.lock b/Cargo.lock index c46d89bab..67ddff020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2816,7 +2816,7 @@ dependencies = [ [[package]] name = "iced_baseview" version = "0.0.3" -source = "git+https://github.com/robbert-vdh/iced_baseview.git?branch=feature/update-baseview#df3a852a15cf0e9fcc8d2b32f5718e56780beaf3" +source = "git+https://github.com/Intrect-io/iced_baseview.git?rev=7f1cedb96d5825626abafdab82d8e2940ff5b39c#7f1cedb96d5825626abafdab82d8e2940ff5b39c" dependencies = [ "baseview", "copypasta 0.7.1", @@ -2826,7 +2826,7 @@ dependencies = [ "iced_graphics", "iced_native", "keyboard-types", - "raw-window-handle 0.4.3", + "raw-window-handle 0.5.2", ] [[package]] @@ -3660,7 +3660,7 @@ dependencies = [ "iced_baseview", "nih_plug", "nih_plug_assets", - "raw-window-handle 0.4.3", + "raw-window-handle 0.5.2", "serde", ] diff --git a/README.md b/README.md index 2969287ad..ae0492f2e 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,9 @@ Scroll down for more information on the underlying plugin framework. management. - Full support for receiving and outputting both modern polyphonic note expression events as well as MIDI CCs, channel pressure, and pitch bend for - CLAP and VST3. + CLAP and VST3. The macOS AUv2 wrapper supports MusicDevice MIDI input, + sample-offset MIDI 1.0 output callbacks, SysEx, and the extended Start/Stop + Note API. - MIDI SysEx is also supported. Plugins can define their own structs or sum types to wrap around those messages so they don't need to interact with raw byte buffers in the process function. @@ -182,12 +184,19 @@ cargo xtask bundle gain --release ### Plugin formats -NIH-plug can currently export VST3 and -[CLAP](https://github.com/free-audio/clap) plugins. Exporting a specific plugin -format for a plugin is as simple as calling the `nih_export_!(Foo);` -macro. The `cargo xtask bundle` command will detect which plugin formats your -plugin supports and create the appropriate bundles accordingly, even when cross -compiling. +NIH-plug can currently export VST3, [CLAP](https://github.com/free-audio/clap), +and macOS Audio Unit v2 plugins. Exporting a specific plugin format is as simple +as calling the `nih_export_!(Foo);` macro. The `cargo xtask bundle` +command detects which formats a plugin supports and creates the appropriate +bundles, including `.component` bundles on macOS. + +An Audio Unit implements `AuPlugin` with its type, subtype, and manufacturer +four-character codes, calls `nih_export_au!(Foo)`, and adds the matching +`[package.au]` metadata to `bundler.toml`. Effects (`aufx`), music effects +(`aumf`), instruments (`aumu`), and generators (`augn`) can use the same audio, +parameter, state, editor, and MIDI APIs as the other wrappers. See the +[`sine`](plugins/examples/sine) instrument and [`gain`](plugins/examples/gain) +effect examples. ### Example plugins @@ -210,6 +219,9 @@ examples. - [**midi_inverter**](plugins/examples/midi_inverter) takes note/MIDI events and flips around the note, channel, expression, pressure, and CC values. This example demonstrates how to receive and output those events. +- [**sine**](plugins/examples/sine) is a mono/stereo test-tone instrument that + demonstrates Audio Unit music-device input and MIDI output alongside its CLAP + and VST3 exports. - [**poly_mod_synth**](plugins/examples/poly_mod_synth) is a simple polyphonic synthesizer with support for polyphonic modulation in supported CLAP hosts. This demonstrates how polyphonic modulation can be used in NIH-plug. diff --git a/bundler.toml b/bundler.toml index 8f091df3a..3aa157f9e 100644 --- a/bundler.toml +++ b/bundler.toml @@ -31,6 +31,15 @@ subtype = "GnEG" manufacturer = "MoiP" description = "Gain with egui GUI — nih-plug AU example" +[sine] +name = "Sine Test Tone" + +[sine.au] +type = "aumu" +subtype = "MPsn" +manufacturer = "MoiP" +description = "MIDI-controlled sine instrument — nih-plug AU example" + [soft_vacuum] name = "Soft Vacuum" diff --git a/nih_plug_iced/Cargo.toml b/nih_plug_iced/Cargo.toml index 7b2c98395..76ef326e0 100644 --- a/nih_plug_iced/Cargo.toml +++ b/nih_plug_iced/Cargo.toml @@ -59,14 +59,14 @@ smol = ["iced_baseview/smol"] nih_plug = { path = "..", default-features = false } nih_plug_assets = { git = "https://github.com/robbert-vdh/nih_plug_assets.git" } -# The currently targeted version of baseview uses a different version of -# `raw_window_handle` than NIH-plug, so we need to manually convert between them -raw-window-handle = "0.4" +# Keep this aligned with the patched baseview and iced_baseview revisions. +raw-window-handle = "0.5" atomic_refcell = "0.1" baseview = { git = "https://github.com/RustAudio/baseview.git", rev = "1d9806d5bd92275d0d8142d9c9c90198757b9b25" } crossbeam = "0.8" -# This targets iced 0.4 -iced_baseview = { git = "https://github.com/robbert-vdh/iced_baseview.git", branch = "feature/update-baseview", default-features = false } +# This targets iced 0.4 and carries the raw-window-handle 0.5 compatibility +# needed by the pinned Intrect baseview fork. +iced_baseview = { git = "https://github.com/Intrect-io/iced_baseview.git", rev = "7f1cedb96d5825626abafdab82d8e2940ff5b39c", default-features = false } # To make the state persistable serde = { version = "1.0", features = ["derive"] } diff --git a/nih_plug_iced/src/editor.rs b/nih_plug_iced/src/editor.rs index d2984d270..5fd69bec9 100644 --- a/nih_plug_iced/src/editor.rs +++ b/nih_plug_iced/src/editor.rs @@ -25,25 +25,24 @@ pub(crate) struct IcedEditorWrapper { pub(crate) parameter_updates_receiver: Arc>, } -/// This version of `baseview` uses a different version of `raw_window_handle than NIH-plug, so we -/// need to adapt it ourselves. +/// Adapt nih-plug's platform-neutral parent handle to baseview's raw-window-handle type. struct ParentWindowHandleAdapter(nih_plug::editor::ParentWindowHandle); unsafe impl HasRawWindowHandle for ParentWindowHandleAdapter { fn raw_window_handle(&self) -> RawWindowHandle { match self.0 { ParentWindowHandle::X11Window(window) => { - let mut handle = raw_window_handle::XcbHandle::empty(); + let mut handle = raw_window_handle::XcbWindowHandle::empty(); handle.window = window; RawWindowHandle::Xcb(handle) } ParentWindowHandle::AppKitNsView(ns_view) => { - let mut handle = raw_window_handle::AppKitHandle::empty(); + let mut handle = raw_window_handle::AppKitWindowHandle::empty(); handle.ns_view = ns_view; RawWindowHandle::AppKit(handle) } ParentWindowHandle::Win32Hwnd(hwnd) => { - let mut handle = raw_window_handle::Win32Handle::empty(); + let mut handle = raw_window_handle::Win32WindowHandle::empty(); handle.hwnd = hwnd; RawWindowHandle::Win32(handle) } diff --git a/plugins/examples/sine/Cargo.toml b/plugins/examples/sine/Cargo.toml index 9a3d9ddfb..197bcbaa8 100644 --- a/plugins/examples/sine/Cargo.toml +++ b/plugins/examples/sine/Cargo.toml @@ -8,5 +8,9 @@ license = "ISC" [lib] crate-type = ["cdylib"] +[features] +default = ["au"] +au = ["nih_plug/au"] + [dependencies] nih_plug = { path = "../../../", features = ["assert_process_allocs"] } diff --git a/plugins/examples/sine/src/lib.rs b/plugins/examples/sine/src/lib.rs index 0bb1a0035..8f2df1a4e 100644 --- a/plugins/examples/sine/src/lib.rs +++ b/plugins/examples/sine/src/lib.rs @@ -120,6 +120,7 @@ impl Plugin for Sine { ]; const MIDI_INPUT: MidiConfig = MidiConfig::Basic; + const MIDI_OUTPUT: MidiConfig = MidiConfig::Basic; const SAMPLE_ACCURATE_AUTOMATION: bool = true; type SysExMessage = (); @@ -166,6 +167,10 @@ impl Plugin for Sine { break; } + // Echo the input so this example also exercises plugins with MIDI output. + // The AU smoke test validates the host callback and sample offset end to end. + context.send_event(event); + match event { NoteEvent::NoteOn { note, velocity, .. } => { self.midi_note_id = note; @@ -226,5 +231,15 @@ impl Vst3Plugin for Sine { ]; } +#[cfg(all(feature = "au", target_os = "macos"))] +impl AuPlugin for Sine { + // Music device with no audio input bus. These values match bundler.toml. + const AU_TYPE: [u8; 4] = *b"aumu"; + const AU_SUBTYPE: [u8; 4] = *b"MPsn"; + const AU_MANUFACTURER: [u8; 4] = *b"MoiP"; +} + nih_export_clap!(Sine); nih_export_vst3!(Sine); +#[cfg(all(feature = "au", target_os = "macos"))] +nih_export_au!(Sine); diff --git a/scripts/au_midi_smoke.sh b/scripts/au_midi_smoke.sh new file mode 100755 index 000000000..c69396b92 --- /dev/null +++ b/scripts/au_midi_smoke.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# AUv2 instrument의 실제 AudioComponent 등록, MusicDevice 입력, audio render, +# MIDI output callback을 한 번에 검증한다. macOS 26의 bare-CLI 탐색 회귀를 +# 피하기 위해 MiniHost를 .app으로 감싸 LaunchServices로 실행한다. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/target/au_midi_smoke" +HOST_SOURCE="$ROOT_DIR/scripts/au_midi_smoke/MiniHost.c" +HOST_PLIST="$ROOT_DIR/scripts/au_midi_smoke/Info.plist" +HOST_BINARY="$BUILD_DIR/MiniHost" +HOST_APP="$BUILD_DIR/MiniHost.app" +RESULT_JSON="$BUILD_DIR/result.json" +AU_SOURCE="$ROOT_DIR/target/bundled/Sine Test Tone.component" +AU_DESTINATION="$HOME/Library/Audio/Plug-Ins/Components/Sine Test Tone.component" +PREVIOUS_AU="$BUILD_DIR/previous/Sine Test Tone.component" +INSTALLED_AU="$BUILD_DIR/installed/Sine Test Tone.component" + +mkdir -p "$BUILD_DIR" "$HOME/Library/Audio/Plug-Ins/Components" + +if [ -e "$PREVIOUS_AU" ]; then + echo "ERROR: 이전 실행의 AU 백업이 남아 있습니다: $PREVIOUS_AU" >&2 + exit 1 +fi + +restore_component() { + if [ -e "$AU_DESTINATION" ]; then + mkdir -p "$(dirname "$INSTALLED_AU")" + if [ -e "$INSTALLED_AU" ]; then + INSTALLED_AU="$BUILD_DIR/installed/Sine Test Tone-$(date +%s).component" + fi + mv "$AU_DESTINATION" "$INSTALLED_AU" + fi + if [ -e "$PREVIOUS_AU" ]; then + mv "$PREVIOUS_AU" "$AU_DESTINATION" + fi + killall -9 AudioComponentRegistrar 2>/dev/null || true +} +trap restore_component EXIT + +if [ -e "$AU_DESTINATION" ]; then + mkdir -p "$(dirname "$PREVIOUS_AU")" + mv "$AU_DESTINATION" "$PREVIOUS_AU" +fi + +cd "$ROOT_DIR" +cargo xtask bundle sine --release +test -d "$AU_SOURCE" +ditto "$AU_SOURCE" "$AU_DESTINATION" +codesign --verify --deep --strict "$AU_DESTINATION" + +xcrun clang -O2 -Wall -Wextra -Werror "$HOST_SOURCE" \ + -framework AudioToolbox -framework CoreMIDI -framework CoreFoundation \ + -o "$HOST_BINARY" + +mkdir -p "$HOST_APP/Contents/MacOS" +cp "$HOST_BINARY" "$HOST_APP/Contents/MacOS/MiniHost" +cp "$HOST_PLIST" "$HOST_APP/Contents/Info.plist" +codesign --force --sign - "$HOST_APP" + +killall -9 AudioComponentRegistrar 2>/dev/null || true +: > "$RESULT_JSON" +open -W -n "$HOST_APP" --args --out "$RESULT_JSON" + +test -f "$RESULT_JSON" +cat "$RESULT_JSON" +if grep -q '"error"' "$RESULT_JSON" || + ! grep -q '"audio_ok":true' "$RESULT_JSON" || + ! grep -q '"note_on_echo":true' "$RESULT_JSON" || + ! grep -q '"note_off_echo":true' "$RESULT_JSON" || + ! grep -q '"last_render_error":0' "$RESULT_JSON" || + ! grep -q '"tail_time_infinite":true' "$RESULT_JSON"; then + exit 1 +fi diff --git a/scripts/au_midi_smoke/Info.plist b/scripts/au_midi_smoke/Info.plist new file mode 100644 index 000000000..091e125c8 --- /dev/null +++ b/scripts/au_midi_smoke/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleExecutable + MiniHost + CFBundleIdentifier + com.nih-plug.au-midi-smoke + CFBundleName + nih-plug AU MIDI Smoke + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + LSUIElement + + + diff --git a/scripts/au_midi_smoke/MiniHost.c b/scripts/au_midi_smoke/MiniHost.c new file mode 100644 index 000000000..78422e7e4 --- /dev/null +++ b/scripts/au_midi_smoke/MiniHost.c @@ -0,0 +1,432 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// macOS 26에서는 bare CLI의 서드파티 AudioComponent 탐색이 깨질 수 있다. +// 이 실행 파일은 .app으로 감싸 LaunchServices를 통해 실행한다. + +typedef struct { + UInt32 count; + MIDITimeStamp timestamps[16]; + UInt16 lengths[16]; + UInt8 bytes[16][3]; +} MidiCapture; + +static const char *result_path = "/tmp/nih_plug_au_midi_smoke.json"; + +static void write_error(const char *stage, OSStatus status) { + FILE *file = fopen(result_path, "w"); + if (file != NULL) { + fprintf(file, "{\"error\":\"%s\",\"status\":%d}\n", stage, (int)status); + fclose(file); + } +} + +static void fail(const char *stage, OSStatus status) { + write_error(stage, status); + exit(1); +} + +static FourCharCode fourcc(const char text[4]) { + return ((FourCharCode)(UInt8)text[0] << 24) | + ((FourCharCode)(UInt8)text[1] << 16) | + ((FourCharCode)(UInt8)text[2] << 8) | + (FourCharCode)(UInt8)text[3]; +} + +static OSStatus midi_output_callback(void *user_data, + const AudioTimeStamp *timestamp, + UInt32 output_number, + const MIDIPacketList *packet_list) { + (void)timestamp; + (void)output_number; + + MidiCapture *capture = (MidiCapture *)user_data; + const MIDIPacket *packet = &packet_list->packet[0]; + for (UInt32 index = 0; index < packet_list->numPackets; ++index) { + if (capture->count < 16) { + const UInt32 slot = capture->count++; + capture->timestamps[slot] = packet->timeStamp; + capture->lengths[slot] = packet->length; + const UInt16 copy_length = packet->length < 3 ? packet->length : 3; + memcpy(capture->bytes[slot], packet->data, copy_length); + } + packet = MIDIPacketNext(packet); + } + + return noErr; +} + +static AudioUnitParameterID find_parameter(AudioUnit unit, const char *wanted_name) { + UInt32 size = 0; + Boolean writable = false; + OSStatus status = AudioUnitGetPropertyInfo( + unit, kAudioUnitProperty_ParameterList, kAudioUnitScope_Global, 0, &size, &writable); + if (status != noErr || size == 0 || size % sizeof(AudioUnitParameterID) != 0) { + fail("GetPropertyInfo(ParameterList)", status); + } + + AudioUnitParameterID *ids = malloc(size); + if (ids == NULL) { + fail("malloc(ParameterList)", kAudioUnitErr_FailedInitialization); + } + status = AudioUnitGetProperty( + unit, kAudioUnitProperty_ParameterList, kAudioUnitScope_Global, 0, ids, &size); + if (status != noErr) { + free(ids); + fail("GetProperty(ParameterList)", status); + } + + const UInt32 count = size / sizeof(AudioUnitParameterID); + AudioUnitParameterID found = UINT32_MAX; + for (UInt32 index = 0; index < count; ++index) { + AudioUnitParameterInfo info; + memset(&info, 0, sizeof(info)); + UInt32 info_size = sizeof(info); + status = AudioUnitGetProperty(unit, + kAudioUnitProperty_ParameterInfo, + kAudioUnitScope_Global, + ids[index], + &info, + &info_size); + if (status != noErr) { + free(ids); + fail("GetProperty(ParameterInfo)", status); + } + + if (strncmp(info.name, wanted_name, sizeof(info.name)) == 0) { + found = ids[index]; + } + // nih-plug의 AU wrapper는 이 두 문자열을 Create Rule(+1)로 반환한다. + if (info.cfNameString != NULL) { + CFRelease(info.cfNameString); + } + if (info.unitName != NULL) { + CFRelease(info.unitName); + } + if (found != UINT32_MAX) { + break; + } + } + + free(ids); + if (found == UINT32_MAX) { + fail("Use MIDI parameter not found", kAudioUnitErr_InvalidParameter); + } + return found; +} + +static AudioBufferList *make_stereo_buffer_list(UInt32 frames, + Float32 **left, + Float32 **right) { + *left = calloc(frames, sizeof(Float32)); + *right = calloc(frames, sizeof(Float32)); + const size_t list_size = offsetof(AudioBufferList, mBuffers) + 2 * sizeof(AudioBuffer); + AudioBufferList *list = calloc(1, list_size); + if (*left == NULL || *right == NULL || list == NULL) { + fail("calloc(AudioBufferList)", kAudioUnitErr_FailedInitialization); + } + + list->mNumberBuffers = 2; + list->mBuffers[0].mNumberChannels = 1; + list->mBuffers[0].mDataByteSize = frames * sizeof(Float32); + list->mBuffers[0].mData = *left; + list->mBuffers[1].mNumberChannels = 1; + list->mBuffers[1].mDataByteSize = frames * sizeof(Float32); + list->mBuffers[1].mData = *right; + return list; +} + +static OSStatus render(AudioUnit unit, + AudioBufferList *list, + UInt32 frames, + Float64 sample_time) { + memset(list->mBuffers[0].mData, 0, frames * sizeof(Float32)); + memset(list->mBuffers[1].mData, 0, frames * sizeof(Float32)); + AudioTimeStamp timestamp; + memset(×tamp, 0, sizeof(timestamp)); + timestamp.mSampleTime = sample_time; + timestamp.mFlags = kAudioTimeStampSampleTimeValid; + AudioUnitRenderActionFlags flags = 0; + return AudioUnitRender(unit, &flags, ×tamp, 0, frames, list); +} + +static double rms(const Float32 *samples, UInt32 count) { + double sum = 0.0; + for (UInt32 index = 0; index < count; ++index) { + sum += (double)samples[index] * (double)samples[index]; + } + return sqrt(sum / (double)count); +} + +static Boolean captured_message(const MidiCapture *capture, + UInt8 status, + UInt8 data1, + MIDITimeStamp timestamp) { + for (UInt32 index = 0; index < capture->count; ++index) { + if (capture->lengths[index] >= 3 && capture->bytes[index][0] == status && + capture->bytes[index][1] == data1 && capture->timestamps[index] == timestamp) { + return true; + } + } + return false; +} + +int main(int argc, const char *argv[]) { + for (int index = 1; index + 1 < argc; index += 2) { + if (strcmp(argv[index], "--out") == 0) { + result_path = argv[index + 1]; + } + } + + AudioComponentDescription description = { + .componentType = fourcc("aumu"), + .componentSubType = fourcc("MPsn"), + .componentManufacturer = fourcc("MoiP"), + .componentFlags = 0, + .componentFlagsMask = 0, + }; + AudioComponent component = AudioComponentFindNext(NULL, &description); + if (component == NULL) { + fail("AudioComponentFindNext", kAudioUnitErr_InvalidProperty); + } + + AudioUnit unit = NULL; + OSStatus status = AudioComponentInstanceNew(component, &unit); + if (status != noErr || unit == NULL) { + fail("AudioComponentInstanceNew", status); + } + + UInt32 input_count = UINT32_MAX; + UInt32 output_count = UINT32_MAX; + UInt32 uint_size = sizeof(UInt32); + status = AudioUnitGetProperty(unit, + kAudioUnitProperty_ElementCount, + kAudioUnitScope_Input, + 0, + &input_count, + &uint_size); + if (status != noErr || input_count != 0) { + fail("instrument input bus count", status); + } + uint_size = sizeof(UInt32); + status = AudioUnitGetProperty(unit, + kAudioUnitProperty_ElementCount, + kAudioUnitScope_Output, + 0, + &output_count, + &uint_size); + if (status != noErr || output_count != 1) { + fail("instrument output bus count", status); + } + + UInt32 supports_start_stop = 0; + uint_size = sizeof(UInt32); + status = AudioUnitGetProperty( + unit, 1014, kAudioUnitScope_Global, 0, &supports_start_stop, &uint_size); + if (status != noErr || supports_start_stop != 1) { + fail("SupportsStartStopNote", status); + } + + CFArrayRef output_names = NULL; + UInt32 output_names_size = sizeof(output_names); + status = AudioUnitGetProperty(unit, + kAudioUnitProperty_MIDIOutputCallbackInfo, + kAudioUnitScope_Global, + 0, + &output_names, + &output_names_size); + if (status != noErr || output_names == NULL || CFArrayGetCount(output_names) != 1) { + fail("MIDIOutputCallbackInfo", status); + } + CFRelease(output_names); + + MidiCapture capture; + memset(&capture, 0, sizeof(capture)); + AUMIDIOutputCallbackStruct output_callback = { + .midiOutputCallback = midi_output_callback, + .userData = &capture, + }; + status = AudioUnitSetProperty(unit, + kAudioUnitProperty_MIDIOutputCallback, + kAudioUnitScope_Global, + 0, + &output_callback, + sizeof(output_callback)); + if (status != noErr) { + fail("SetProperty(MIDIOutputCallback)", status); + } + + AudioStreamBasicDescription format = { + .mSampleRate = 48000.0, + .mFormatID = kAudioFormatLinearPCM, + .mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved, + .mBytesPerPacket = sizeof(Float32), + .mFramesPerPacket = 1, + .mBytesPerFrame = sizeof(Float32), + .mChannelsPerFrame = 2, + .mBitsPerChannel = 32, + .mReserved = 0, + }; + status = AudioUnitSetProperty(unit, + kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, + 0, + &format, + sizeof(format)); + if (status != noErr) { + fail("SetProperty(StreamFormat)", status); + } + + UInt32 max_frames = 512; + status = AudioUnitSetProperty(unit, + kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, + 0, + &max_frames, + sizeof(max_frames)); + if (status != noErr) { + fail("SetProperty(MaximumFramesPerSlice)", status); + } + + const AudioUnitParameterID use_midi = find_parameter(unit, "Use MIDI"); + status = AudioUnitSetParameter(unit, use_midi, kAudioUnitScope_Global, 0, 1.0f, 0); + if (status != noErr) { + fail("SetParameter(Use MIDI)", status); + } + + status = AudioUnitInitialize(unit); + if (status != noErr) { + fail("AudioUnitInitialize", status); + } + + Float32 *left = NULL; + Float32 *right = NULL; + AudioBufferList *list = make_stereo_buffer_list(max_frames, &left, &right); + status = render(unit, list, max_frames, 0.0); + if (status != noErr) { + fail("pre-note AudioUnitRender", status); + } + const double pre_note_rms = rms(left, max_frames); + + status = MusicDeviceMIDIEvent(unit, 0x90, 69, 100, 64); + if (status != noErr) { + fail("MusicDeviceMIDIEvent(NoteOn)", status); + } + status = render(unit, list, max_frames, 512.0); + if (status != noErr) { + fail("note-on AudioUnitRender", status); + } + const double note_on_rms = rms(left, max_frames); + + status = MusicDeviceMIDIEvent(unit, 0x80, 69, 0, 16); + if (status != noErr) { + fail("MusicDeviceMIDIEvent(NoteOff)", status); + } + status = render(unit, list, max_frames, 1024.0); + if (status != noErr) { + fail("note-off AudioUnitRender", status); + } + + const UInt8 sysex[] = {0xF0, 0x7E, 0x00, 0xF7}; + status = MusicDeviceSysEx(unit, sysex, sizeof(sysex)); + if (status != noErr) { + fail("MusicDeviceSysEx", status); + } + + MusicDeviceStdNoteParams note_params = { + .argCount = 2, + .mPitch = 72.5f, + .mVelocity = 96.0f, + }; + NoteInstanceID note_id = 0; + status = MusicDeviceStartNote(unit, + kMusicNoteEvent_UseGroupInstrument, + 0, + ¬e_id, + 32, + (const MusicDeviceNoteParams *)¬e_params); + if (status != noErr || note_id == 0) { + fail("MusicDeviceStartNote", status); + } + status = render(unit, list, max_frames, 1536.0); + if (status != noErr) { + fail("start-note AudioUnitRender", status); + } + status = MusicDeviceStopNote(unit, 0, note_id, 24); + if (status != noErr) { + fail("MusicDeviceStopNote", status); + } + status = render(unit, list, max_frames, 2048.0); + if (status != noErr) { + fail("stop-note AudioUnitRender", status); + } + + OSStatus last_render_error = -1; + UInt32 status_size = sizeof(last_render_error); + status = AudioUnitGetProperty(unit, + kAudioUnitProperty_LastRenderError, + kAudioUnitScope_Global, + 0, + &last_render_error, + &status_size); + if (status != noErr || last_render_error != noErr) { + fail("LastRenderError", status); + } + + Float64 tail_time = 0.0; + UInt32 tail_time_size = sizeof(tail_time); + status = AudioUnitGetProperty(unit, + kAudioUnitProperty_TailTime, + kAudioUnitScope_Global, + 0, + &tail_time, + &tail_time_size); + const Boolean tail_time_infinite = status == noErr && isinf(tail_time); + if (!tail_time_infinite) { + fail("TailTime", status); + } + + const Boolean note_on_echo = captured_message(&capture, 0x90, 69, 64); + const Boolean note_off_echo = captured_message(&capture, 0x80, 69, 16); + const Boolean audio_ok = pre_note_rms < 0.000001 && note_on_rms > 0.001; + + AudioUnitUninitialize(unit); + AudioComponentInstanceDispose(unit); + free(list); + free(left); + free(right); + + FILE *file = fopen(result_path, "w"); + if (file == NULL) { + return 1; + } + fprintf(file, + "{\"component\":\"aumu/MPsn/MoiP\",\"input_buses\":%u," + "\"output_buses\":%u,\"pre_note_rms\":%.9f,\"note_on_rms\":%.9f," + "\"audio_ok\":%s,\"midi_output_packets\":%u,\"note_on_echo\":%s," + "\"note_off_echo\":%s,\"start_stop_note_id\":%u," + "\"last_render_error\":%d,\"tail_time_infinite\":%s}\n", + input_count, + output_count, + pre_note_rms, + note_on_rms, + audio_ok ? "true" : "false", + capture.count, + note_on_echo ? "true" : "false", + note_off_echo ? "true" : "false", + note_id, + (int)last_render_error, + tail_time_infinite ? "true" : "false"); + fclose(file); + + return audio_ok && note_on_echo && note_off_echo ? 0 : 1; +} diff --git a/src/midi.rs b/src/midi.rs index a3363eee4..88848eefb 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -651,6 +651,33 @@ impl NoteEvent { NoteEvent::MidiSysEx { timing, .. } => *timing -= samples, } } + + /// Clamp this event's sample offset to the last valid frame in a process + /// block. Wrappers call this at their host boundary so plugins never need + /// to defend against out-of-range host timestamps. + #[cfg_attr(not(feature = "au"), allow(dead_code))] + pub(crate) fn clamp_timing(&mut self, last_frame: u32) { + match self { + NoteEvent::NoteOn { timing, .. } + | NoteEvent::NoteOff { timing, .. } + | NoteEvent::Choke { timing, .. } + | NoteEvent::VoiceTerminated { timing, .. } + | NoteEvent::PolyModulation { timing, .. } + | NoteEvent::MonoAutomation { timing, .. } + | NoteEvent::PolyPressure { timing, .. } + | NoteEvent::PolyVolume { timing, .. } + | NoteEvent::PolyPan { timing, .. } + | NoteEvent::PolyTuning { timing, .. } + | NoteEvent::PolyVibrato { timing, .. } + | NoteEvent::PolyExpression { timing, .. } + | NoteEvent::PolyBrightness { timing, .. } + | NoteEvent::MidiChannelPressure { timing, .. } + | NoteEvent::MidiPitchBend { timing, .. } + | NoteEvent::MidiCC { timing, .. } + | NoteEvent::MidiProgramChange { timing, .. } + | NoteEvent::MidiSysEx { timing, .. } => *timing = (*timing).min(last_frame), + } + } } #[cfg(test)] diff --git a/src/wrapper/au.rs b/src/wrapper/au.rs index f782f76da..49f38e696 100644 --- a/src/wrapper/au.rs +++ b/src/wrapper/au.rs @@ -7,6 +7,7 @@ mod context; mod factory; +mod midi; mod wrapper; pub use factory::fourcc; diff --git a/src/wrapper/au/context.rs b/src/wrapper/au/context.rs index 3fe9f73f9..221104910 100644 --- a/src/wrapper/au/context.rs +++ b/src/wrapper/au/context.rs @@ -1,5 +1,6 @@ //! Minimal `InitContext` / `ProcessContext` / `GuiContext` impls for the AU wrapper. +use std::collections::VecDeque; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; @@ -10,8 +11,8 @@ use crate::context::init::InitContext; use crate::context::process::{ProcessContext, Transport}; use crate::context::PluginApi; use crate::params::internals::ParamPtr; -use crate::prelude::PluginNoteEvent; use crate::plugin::Plugin; +use crate::prelude::PluginNoteEvent; use crate::wrapper::state::PluginState; /// Cell shared between the wrapper and its `InitContext` / `ProcessContext` @@ -52,13 +53,15 @@ impl InitContext

for AuInitContext

{ } } -pub(super) struct AuProcessContext { +pub(super) struct AuProcessContext<'a, P: Plugin> { pub sink: Arc, pub transport: Transport, + pub input_events: &'a mut VecDeque>, + pub output_events: &'a mut VecDeque>, pub _marker: std::marker::PhantomData

, } -impl ProcessContext

for AuProcessContext

{ +impl ProcessContext

for AuProcessContext<'_, P> { fn plugin_api(&self) -> PluginApi { PluginApi::Au } @@ -72,10 +75,16 @@ impl ProcessContext

for AuProcessContext

{ } fn next_event(&mut self) -> Option> { - None + self.input_events.pop_front() } - fn send_event(&mut self, _event: PluginNoteEvent

) {} + fn send_event(&mut self, event: PluginNoteEvent

) { + if self.output_events.len() < self.output_events.capacity() { + self.output_events.push_back(event); + } else { + nih_debug_assert_failure!("The AU MIDI output queue is full, dropping event"); + } + } fn set_latency_samples(&self, samples: u32) { self.sink.latency_samples.store(samples, Ordering::Relaxed); @@ -136,8 +145,11 @@ impl GuiContext for AuGuiContext

{ .find(|(p, _)| *p == param) .map(|(_, id)| id) { - let instance = self.inner.instance_bits.load(std::sync::atomic::Ordering::Acquire) - as usize as au::AudioUnit; + let instance = self + .inner + .instance_bits + .load(std::sync::atomic::Ordering::Acquire) as usize + as au::AudioUnit; let au_param = AUParameter { mAudioUnit: instance, mParameterID: param_id, @@ -145,7 +157,9 @@ impl GuiContext for AuGuiContext

{ mElement: 0, }; // SAFETY: AUParameterListenerNotify is safe from any thread. - unsafe { AUParameterListenerNotify(std::ptr::null_mut(), std::ptr::null_mut(), &au_param) }; + unsafe { + AUParameterListenerNotify(std::ptr::null_mut(), std::ptr::null_mut(), &au_param) + }; } } @@ -159,9 +173,7 @@ impl GuiContext for AuGuiContext

{ unsafe { crate::wrapper::state::serialize_object::

( params_arc.clone(), - param_map - .iter() - .map(|(id_str, ptr, _group)| (id_str, *ptr)), + param_map.iter().map(|(id_str, ptr, _group)| (id_str, *ptr)), ) } } @@ -176,12 +188,7 @@ impl GuiContext for AuGuiContext

{ .map(|(_, ptr, _)| *ptr) }; unsafe { - crate::wrapper::state::deserialize_object::

( - &mut state, - params_arc, - getter, - None, - ); + crate::wrapper::state::deserialize_object::

(&mut state, params_arc, getter, None); } } } diff --git a/src/wrapper/au/midi.rs b/src/wrapper/au/midi.rs new file mode 100644 index 000000000..e791674bc --- /dev/null +++ b/src/wrapper/au/midi.rs @@ -0,0 +1,961 @@ +//! AUv2 MIDI input/output plumbing. +//! +//! `au-sys` 0.1.1 does not expose the MusicDevice selectors or the legacy +//! MIDI-output callback structs, so the small C ABI surface needed by the AU +//! wrapper is mirrored here from the macOS SDK headers. + +use std::borrow::Borrow; +use std::collections::VecDeque; +use std::ffi::c_void; +use std::mem; +use std::sync::atomic::{AtomicPtr, AtomicU32, AtomicU64, Ordering}; +use std::sync::Mutex; + +use au_sys as au; +use crossbeam::queue::ArrayQueue; + +use crate::midi::{MidiResult, NoteEvent}; +use crate::plugin::Plugin; +use crate::prelude::{MidiConfig, PluginNoteEvent}; + +pub(super) const MUSIC_DEVICE_MIDI_EVENT_SELECT: au::SInt16 = 0x0101; +pub(super) const MUSIC_DEVICE_SYS_EX_SELECT: au::SInt16 = 0x0102; +pub(super) const MUSIC_DEVICE_START_NOTE_SELECT: au::SInt16 = 0x0105; +pub(super) const MUSIC_DEVICE_STOP_NOTE_SELECT: au::SInt16 = 0x0106; +pub(super) const MUSIC_DEVICE_PROPERTY_SUPPORTS_START_STOP_NOTE: au::AudioUnitPropertyID = 1014; + +const MUSIC_DEVICE_SAMPLE_FRAME_MASK: u32 = 0x00ff_ffff; +const MIDI_EVENT_QUEUE_CAPACITY: usize = 1024; +const MIDI_RENDER_EVENT_CAPACITY: usize = MIDI_EVENT_QUEUE_CAPACITY * 2; +const ACTIVE_NOTE_CAPACITY: usize = 1024; +const STOPPING_NOTE_BIT: u64 = 1 << 63; +const MIDI_PACKET_LIST_BYTES: usize = 65_536; +const MIDI_PACKET_CHUNK_BYTES: usize = 60_000; + +pub(super) type MusicDeviceMidiEventProc = unsafe extern "C" fn( + *mut c_void, + au::UInt32, + au::UInt32, + au::UInt32, + au::UInt32, +) -> au::OSStatus; +pub(super) type MusicDeviceSysExProc = + unsafe extern "C" fn(*mut c_void, *const u8, au::UInt32) -> au::OSStatus; +pub(super) type MusicDeviceStartNoteProc = unsafe extern "C" fn( + *mut c_void, + au::UInt32, + au::UInt32, + *mut au::UInt32, + au::UInt32, + *const MusicDeviceNoteParams, +) -> au::OSStatus; +pub(super) type MusicDeviceStopNoteProc = + unsafe extern "C" fn(*mut c_void, au::UInt32, au::UInt32, au::UInt32) -> au::OSStatus; + +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct NoteParamsControlValue { + pub id: au::AudioUnitParameterID, + pub value: au::AudioUnitParameterValue, +} + +/// Variable-length in C. The wrapper only consumes the required pitch and +/// velocity prefix, so one trailing control is enough to mirror its ABI. +#[repr(C)] +pub(super) struct MusicDeviceNoteParams { + pub arg_count: au::UInt32, + pub pitch: au::Float32, + pub velocity: au::Float32, + pub controls: [NoteParamsControlValue; 1], +} + +pub(super) type AuMidiOutputCallback = unsafe extern "C" fn( + *mut c_void, + *const au::AudioTimeStamp, + au::UInt32, + *const c_void, +) -> au::OSStatus; + +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct AuMidiOutputCallbackStruct { + pub callback: Option, + pub user_data: *mut c_void, +} + +// The host owns `user_data`; the wrapper only forwards it to the callback. +unsafe impl Send for AuMidiOutputCallbackStruct {} +unsafe impl Sync for AuMidiOutputCallbackStruct {} + +/// Lock-free render-side storage for the host's MIDI output callback. +/// +/// Property writes happen on a control thread and may allocate. Each installed +/// record remains owned by `records` until the AudioUnit is destroyed, so the +/// render thread can atomically load and copy a stable record without a mutex, +/// reference-count operation, or reclamation race. Hosts retain responsibility +/// for keeping the opaque `user_data` target alive while callbacks may be in +/// flight, as required by the AU callback contract. +pub(super) struct MidiOutputCallbackSlot { + current: AtomicPtr, + records: Mutex>>, +} + +impl MidiOutputCallbackSlot { + pub fn new() -> Self { + Self { + current: AtomicPtr::new(std::ptr::null_mut()), + records: Mutex::new(Vec::new()), + } + } + + pub fn store(&self, callback: Option) -> Result<(), ()> { + let Some(callback) = callback.filter(|callback| callback.callback.is_some()) else { + self.current.store(std::ptr::null_mut(), Ordering::Release); + return Ok(()); + }; + + let record = Box::new(callback); + let record_ptr = + record.as_ref() as *const AuMidiOutputCallbackStruct as *mut AuMidiOutputCallbackStruct; + let mut records = self.records.lock().map_err(|_| ())?; + records.push(record); + self.current.store(record_ptr, Ordering::Release); + Ok(()) + } + + #[inline] + pub fn load(&self) -> Option { + let record = self.current.load(Ordering::Acquire); + if record.is_null() { + None + } else { + // SAFETY: records are never mutated or reclaimed until this slot is + // dropped, and AU teardown is serialized against render. + Some(unsafe { *record }) + } + } +} + +pub(super) struct QueuedMidiEvents { + sequence: u64, + first: PluginNoteEvent

, + second: Option>, +} + +/// Multi-producer queue used by the MusicDevice selector calls. Hosts may call +/// these selectors from either their render thread or a control thread, while +/// `AudioUnitRender` is the single consumer. +pub(super) struct MidiInputState { + queue: ArrayQueue>, + sequence: AtomicU64, + next_note_id: AtomicU32, + active_notes: Box<[AtomicU64]>, +} + +impl MidiInputState

{ + pub fn new() -> Self { + Self { + queue: ArrayQueue::new(MIDI_EVENT_QUEUE_CAPACITY), + sequence: AtomicU64::new(0), + next_note_id: AtomicU32::new(1), + active_notes: (0..ACTIVE_NOTE_CAPACITY) + .map(|_| AtomicU64::new(0)) + .collect(), + } + } + + fn push(&self, first: PluginNoteEvent

, second: Option>) -> au::OSStatus { + let event = QueuedMidiEvents { + sequence: self.sequence.fetch_add(1, Ordering::Relaxed), + first, + second, + }; + + if self.queue.push(event).is_err() { + au::kAudioUnitErr_TooManyFramesToProcess + } else { + au::noErr + } + } + + pub fn push_midi_event( + &self, + status: au::UInt32, + data_1: au::UInt32, + data_2: au::UInt32, + offset: au::UInt32, + ) -> au::OSStatus { + if P::MIDI_INPUT < MidiConfig::Basic { + return au::noErr; + } + if status > u8::MAX as u32 + || !(0x80..0xf0).contains(&(status as u8)) + || data_1 > 127 + || data_2 > 127 + { + return au::kAudioUnitErr_InvalidParameter; + } + + let timing = offset & MUSIC_DEVICE_SAMPLE_FRAME_MASK; + let bytes = [status as u8, data_1 as u8, data_2 as u8]; + match NoteEvent::from_midi(timing, &bytes) { + Ok(event) if input_event_allowed::

(&event) => self.push(event, None), + // Hosts should not need to special-case a plugin's MIDI dialect. + // Unsupported messages are accepted and ignored, matching the + // CLAP/VST3 wrappers. + _ => au::noErr, + } + } + + pub unsafe fn push_sysex(&self, data: *const u8, length: au::UInt32) -> au::OSStatus { + if P::MIDI_INPUT < MidiConfig::Basic { + return au::noErr; + } + if data.is_null() || length < 2 { + return au::kAudioUnitErr_InvalidParameter; + } + + let bytes = unsafe { std::slice::from_raw_parts(data, length as usize) }; + if bytes.first() != Some(&0xf0) || bytes.last() != Some(&0xf7) { + return au::kAudioUnitErr_InvalidParameter; + } + + match NoteEvent::from_midi(0, bytes) { + Ok(event @ NoteEvent::MidiSysEx { .. }) => self.push(event, None), + _ => au::noErr, + } + } + + pub unsafe fn start_note( + &self, + group: au::UInt32, + out_note_id: *mut au::UInt32, + offset: au::UInt32, + params: *const MusicDeviceNoteParams, + ) -> au::OSStatus { + if P::MIDI_INPUT < MidiConfig::Basic { + return au::kAudioUnitErr_CannotDoInCurrentContext; + } + if group >= 16 || out_note_id.is_null() || params.is_null() { + return au::kAudioUnitErr_InvalidParameter; + } + + let params = unsafe { &*params }; + if params.arg_count < 2 + || !params.pitch.is_finite() + || !(0.0..128.0).contains(¶ms.pitch) + || !params.velocity.is_finite() + || !(0.0..=127.0).contains(¶ms.velocity) + { + return au::kAudioUnitErr_InvalidParameter; + } + + let rounded_pitch = params.pitch.round().clamp(0.0, 127.0); + let note = rounded_pitch as u8; + let (note_id, slot, packed) = match self.reserve_active_note(group as u8, note) { + Some(active) => active, + None => return au::kAudioUnitErr_CannotDoInCurrentContext, + }; + let timing = offset & MUSIC_DEVICE_SAMPLE_FRAME_MASK; + let voice_id = Some(note_id as i32); + let note_on = NoteEvent::NoteOn { + timing, + voice_id, + channel: group as u8, + note, + velocity: params.velocity / 127.0, + }; + let tuning = params.pitch - rounded_pitch; + let tuning_event = (tuning.abs() > f32::EPSILON).then_some(NoteEvent::PolyTuning { + timing, + voice_id, + channel: group as u8, + note, + tuning, + }); + + let status = self.push(note_on, tuning_event); + if status != au::noErr { + let _ = self.active_notes[slot].compare_exchange( + packed, + 0, + Ordering::AcqRel, + Ordering::Acquire, + ); + return status; + } + + unsafe { *out_note_id = note_id }; + au::noErr + } + + pub fn stop_note( + &self, + group: au::UInt32, + note_id: au::UInt32, + offset: au::UInt32, + ) -> au::OSStatus { + if P::MIDI_INPUT < MidiConfig::Basic { + return au::kAudioUnitErr_CannotDoInCurrentContext; + } + if group >= 16 || note_id == 0 || note_id > i32::MAX as u32 { + return au::kAudioUnitErr_InvalidParameter; + } + + for slot in self.active_notes.iter() { + let packed = slot.load(Ordering::Acquire); + if packed & STOPPING_NOTE_BIT != 0 + || unpack_note_id(packed) != note_id + || unpack_channel(packed) != group as u8 + { + continue; + } + let claimed = packed | STOPPING_NOTE_BIT; + if slot + .compare_exchange(packed, claimed, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + continue; + } + + let status = self.push( + NoteEvent::NoteOff { + timing: offset & MUSIC_DEVICE_SAMPLE_FRAME_MASK, + voice_id: Some(note_id as i32), + channel: group as u8, + note: unpack_note(packed), + velocity: 0.0, + }, + None, + ); + if status == au::noErr { + slot.store(0, Ordering::Release); + } else { + let _ = slot.compare_exchange(claimed, packed, Ordering::AcqRel, Ordering::Acquire); + } + return status; + } + + au::kAudioUnitErr_InvalidParameter + } + + fn reserve_active_note(&self, channel: u8, note: u8) -> Option<(u32, usize, u64)> { + for _ in 0..ACTIVE_NOTE_CAPACITY { + let note_id = self + .next_note_id + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + Some(if current >= i32::MAX as u32 { + 1 + } else { + current + 1 + }) + }) + .ok()?; + if self + .active_notes + .iter() + .any(|slot| unpack_note_id(slot.load(Ordering::Acquire)) == note_id) + { + continue; + } + + let packed = pack_active_note(note_id, channel, note); + for (idx, slot) in self.active_notes.iter().enumerate() { + if slot + .compare_exchange(0, packed, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Some((note_id, idx, packed)); + } + } + return None; + } + None + } + + pub fn drain_into(&self, state: &mut MidiRenderState

, number_frames: u32) { + state.input_batches.clear(); + state.input_events.clear(); + state.output_events.clear(); + + // Do not consume events that arrive after this render started. They are + // intended for the next block and remain queued. + let queued_at_start = self.queue.len().min(MIDI_EVENT_QUEUE_CAPACITY); + for _ in 0..queued_at_start { + if let Some(batch) = self.queue.pop() { + state.input_batches.push(batch); + } + } + state + .input_batches + .sort_unstable_by_key(|batch| (batch.first.timing(), batch.sequence)); + + let last_frame = number_frames.saturating_sub(1); + for mut batch in state.input_batches.drain(..) { + batch.first.clamp_timing(last_frame); + state.input_events.push_back(batch.first); + if let Some(mut second) = batch.second { + second.clamp_timing(last_frame); + state.input_events.push_back(second); + } + } + } + + pub fn clear(&self) { + while self.queue.pop().is_some() {} + for slot in self.active_notes.iter() { + slot.store(0, Ordering::Release); + } + } +} + +fn input_event_allowed(event: &PluginNoteEvent

) -> bool { + match event { + NoteEvent::NoteOn { .. } + | NoteEvent::NoteOff { .. } + | NoteEvent::PolyPressure { .. } + | NoteEvent::MidiSysEx { .. } => P::MIDI_INPUT >= MidiConfig::Basic, + NoteEvent::MidiChannelPressure { .. } + | NoteEvent::MidiPitchBend { .. } + | NoteEvent::MidiCC { .. } + | NoteEvent::MidiProgramChange { .. } => P::MIDI_INPUT >= MidiConfig::MidiCCs, + _ => false, + } +} + +fn output_event_allowed(event: &PluginNoteEvent

) -> bool { + match event { + NoteEvent::NoteOn { .. } + | NoteEvent::NoteOff { .. } + | NoteEvent::PolyPressure { .. } + | NoteEvent::MidiSysEx { .. } => P::MIDI_OUTPUT >= MidiConfig::Basic, + NoteEvent::MidiChannelPressure { .. } + | NoteEvent::MidiPitchBend { .. } + | NoteEvent::MidiCC { .. } + | NoteEvent::MidiProgramChange { .. } => P::MIDI_OUTPUT >= MidiConfig::MidiCCs, + _ => false, + } +} + +fn pack_active_note(note_id: u32, channel: u8, note: u8) -> u64 { + ((note_id as u64) << 32) | ((channel as u64) << 8) | note as u64 +} + +fn unpack_note_id(packed: u64) -> u32 { + ((packed & !STOPPING_NOTE_BIT) >> 32) as u32 +} + +fn unpack_channel(packed: u64) -> u8 { + ((packed >> 8) & 0xff) as u8 +} + +fn unpack_note(packed: u64) -> u8 { + (packed & 0xff) as u8 +} + +pub(super) struct MidiRenderState { + input_batches: Vec>, + pub input_events: VecDeque>, + pub output_events: VecDeque>, + packet_storage: Vec, +} + +impl MidiRenderState

{ + pub fn new() -> Self { + Self { + input_batches: Vec::with_capacity(MIDI_EVENT_QUEUE_CAPACITY), + input_events: VecDeque::with_capacity(MIDI_RENDER_EVENT_CAPACITY), + output_events: VecDeque::with_capacity(MIDI_EVENT_QUEUE_CAPACITY), + packet_storage: vec![0; MIDI_PACKET_LIST_BYTES / mem::size_of::()], + } + } + + pub fn clear(&mut self) { + self.input_batches.clear(); + self.input_events.clear(); + self.output_events.clear(); + } + + pub unsafe fn flush_output( + &mut self, + callback: Option, + time_stamp: *const au::AudioTimeStamp, + number_frames: u32, + ) -> au::OSStatus { + let callback = match callback.filter(|cb| cb.callback.is_some()) { + Some(callback) if P::MIDI_OUTPUT >= MidiConfig::Basic => callback, + _ => { + self.output_events.clear(); + return au::noErr; + } + }; + + let mut builder = + unsafe { MidiPacketListBuilder::new(callback, time_stamp, &mut self.packet_storage) }; + while let Some(mut event) = self.output_events.pop_front() { + if !output_event_allowed::

(&event) { + nih_debug_assert_failure!( + "Invalid AU output event for the current MIDI_OUTPUT setting" + ); + continue; + } + event.clamp_timing(number_frames.saturating_sub(1)); + let timing = event.timing() as u64; + match event.as_midi() { + Some(MidiResult::Basic(bytes)) => { + let length = match bytes[0] & 0xf0 { + 0xc0 | 0xd0 => 2, + _ => 3, + }; + if let Err(status) = unsafe { builder.push_message(timing, &bytes[..length]) } { + return status; + } + } + Some(MidiResult::SysEx(buffer, length)) => { + let bytes = buffer.borrow(); + if let Err(status) = unsafe { builder.push_message(timing, &bytes[..length]) } { + return status; + } + } + None => { + nih_debug_assert_failure!("AU cannot encode this note expression as MIDI 1.0"); + } + } + } + + match unsafe { builder.flush() } { + Ok(()) => au::noErr, + Err(status) => status, + } + } +} + +struct MidiPacketListBuilder<'a> { + callback: AuMidiOutputCallbackStruct, + time_stamp: *const au::AudioTimeStamp, + storage: &'a mut [u64], + current_packet: *mut c_void, + has_packets: bool, +} + +impl<'a> MidiPacketListBuilder<'a> { + unsafe fn new( + callback: AuMidiOutputCallbackStruct, + time_stamp: *const au::AudioTimeStamp, + storage: &'a mut [u64], + ) -> Self { + let current_packet = unsafe { midi_packet_list_init(storage.as_mut_ptr() as *mut c_void) }; + Self { + callback, + time_stamp, + storage, + current_packet, + has_packets: false, + } + } + + unsafe fn reset(&mut self) { + self.current_packet = + unsafe { midi_packet_list_init(self.storage.as_mut_ptr() as *mut c_void) }; + self.has_packets = false; + } + + unsafe fn push_message(&mut self, timing: u64, data: &[u8]) -> Result<(), au::OSStatus> { + let mut offset = 0; + while offset < data.len() { + let end = (offset + MIDI_PACKET_CHUNK_BYTES).min(data.len()); + let chunk = &data[offset..end]; + let mut added = unsafe { + midi_packet_list_add( + self.storage.as_mut_ptr() as *mut c_void, + MIDI_PACKET_LIST_BYTES, + self.current_packet, + timing, + chunk.len(), + chunk.as_ptr(), + ) + }; + if added.is_null() && self.has_packets { + unsafe { self.flush()? }; + added = unsafe { + midi_packet_list_add( + self.storage.as_mut_ptr() as *mut c_void, + MIDI_PACKET_LIST_BYTES, + self.current_packet, + timing, + chunk.len(), + chunk.as_ptr(), + ) + }; + } + if added.is_null() { + return Err(au::kAudioUnitErr_CannotDoInCurrentContext); + } + self.current_packet = added; + self.has_packets = true; + offset = end; + if offset < data.len() { + unsafe { self.flush()? }; + } + } + Ok(()) + } + + unsafe fn flush(&mut self) -> Result<(), au::OSStatus> { + if !self.has_packets { + return Ok(()); + } + let callback = self + .callback + .callback + .expect("callback checked by constructor caller"); + let status = unsafe { + callback( + self.callback.user_data, + self.time_stamp, + 0, + self.storage.as_ptr() as *const c_void, + ) + }; + if status != au::noErr { + return Err(status); + } + unsafe { self.reset() }; + Ok(()) + } +} + +#[link(name = "CoreMIDI", kind = "framework")] +extern "C" { + #[link_name = "MIDIPacketListInit"] + fn midi_packet_list_init(packet_list: *mut c_void) -> *mut c_void; + #[link_name = "MIDIPacketListAdd"] + fn midi_packet_list_add( + packet_list: *mut c_void, + list_size: usize, + current_packet: *mut c_void, + time: u64, + data_length: usize, + data: *const u8, + ) -> *mut c_void; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::prelude::*; + + #[derive(Debug, Clone, Copy, PartialEq)] + struct TestSysEx([u8; 4]); + + impl SysExMessage for TestSysEx { + type Buffer = [u8; 4]; + + fn from_buffer(buffer: &[u8]) -> Option { + (buffer.len() == 4).then(|| Self(buffer.try_into().unwrap())) + } + + fn to_buffer(self) -> (Self::Buffer, usize) { + (self.0, self.0.len()) + } + } + + #[derive(Default)] + struct TestParams {} + + unsafe impl Params for TestParams { + fn param_map(&self) -> Vec<(String, ParamPtr, String)> { + Vec::new() + } + } + + struct TestPlugin { + params: Arc, + } + + impl Default for TestPlugin { + fn default() -> Self { + Self { + params: Arc::new(TestParams::default()), + } + } + } + + impl Plugin for TestPlugin { + const NAME: &'static str = "AU MIDI Test"; + const VENDOR: &'static str = "NIH-plug"; + const URL: &'static str = "https://github.com/robbert-vdh/nih-plug"; + const EMAIL: &'static str = "test@example.com"; + const VERSION: &'static str = "0.0.0"; + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[]; + const MIDI_INPUT: MidiConfig = MidiConfig::MidiCCs; + const MIDI_OUTPUT: MidiConfig = MidiConfig::MidiCCs; + + type SysExMessage = TestSysEx; + type BackgroundTask = (); + + fn params(&self) -> Arc { + self.params.clone() + } + + fn process( + &mut self, + _buffer: &mut Buffer, + _aux: &mut AuxiliaryBuffers, + _context: &mut impl ProcessContext, + ) -> ProcessStatus { + ProcessStatus::Normal + } + } + + #[test] + fn music_device_channel_messages_are_sorted_and_clamped() { + let input = MidiInputState::::new(); + let mut render = MidiRenderState::::new(); + + assert_eq!(input.push_midi_event(0x91, 60, 100, 20), au::noErr); + assert_eq!(input.push_midi_event(0x81, 60, 64, 1), au::noErr); + assert_eq!(input.push_midi_event(0xa1, 60, 32, 2), au::noErr); + assert_eq!(input.push_midi_event(0xb1, 7, 100, 3), au::noErr); + assert_eq!(input.push_midi_event(0xc1, 12, 0, 4), au::noErr); + assert_eq!(input.push_midi_event(0xd1, 48, 0, 5), au::noErr); + assert_eq!(input.push_midi_event(0xe1, 0, 64, 6), au::noErr); + + input.drain_into(&mut render, 8); + assert_eq!(render.input_events.len(), 7); + let timings: Vec<_> = render.input_events.iter().map(NoteEvent::timing).collect(); + assert_eq!(timings, vec![1, 2, 3, 4, 5, 6, 7]); + assert!(matches!(render.input_events[0], NoteEvent::NoteOff { .. })); + assert!(matches!( + render.input_events[1], + NoteEvent::PolyPressure { .. } + )); + assert!(matches!(render.input_events[2], NoteEvent::MidiCC { .. })); + assert!(matches!( + render.input_events[3], + NoteEvent::MidiProgramChange { .. } + )); + assert!(matches!( + render.input_events[4], + NoteEvent::MidiChannelPressure { .. } + )); + assert!(matches!( + render.input_events[5], + NoteEvent::MidiPitchBend { .. } + )); + assert!(matches!(render.input_events[6], NoteEvent::NoteOn { .. })); + } + + #[test] + fn music_device_sysex_uses_the_plugin_parser() { + let input = MidiInputState::::new(); + let mut render = MidiRenderState::::new(); + let message = [0xf0, 0x01, 0x02, 0xf7]; + + assert_eq!( + unsafe { input.push_sysex(message.as_ptr(), message.len() as u32) }, + au::noErr + ); + input.drain_into(&mut render, 32); + assert_eq!( + render.input_events.pop_front(), + Some(NoteEvent::MidiSysEx { + timing: 0, + message: TestSysEx(message), + }) + ); + } + + #[test] + fn midi_input_queue_fails_closed_when_full() { + let input = MidiInputState::::new(); + for _ in 0..MIDI_EVENT_QUEUE_CAPACITY { + assert_eq!(input.push_midi_event(0x90, 60, 100, 0), au::noErr); + } + assert_eq!( + input.push_midi_event(0x90, 61, 100, 0), + au::kAudioUnitErr_TooManyFramesToProcess + ); + } + + #[test] + fn extended_start_stop_preserves_voice_identity_and_fractional_tuning() { + let input = MidiInputState::::new(); + let mut render = MidiRenderState::::new(); + let params = MusicDeviceNoteParams { + arg_count: 2, + pitch: 60.25, + velocity: 63.5, + controls: [NoteParamsControlValue { id: 0, value: 0.0 }], + }; + let mut note_id = 0; + + assert_eq!( + unsafe { input.start_note(3, &mut note_id, 4, ¶ms) }, + au::noErr + ); + assert_ne!(note_id, 0); + assert_eq!(input.stop_note(3, note_id, 9), au::noErr); + + input.drain_into(&mut render, 16); + assert_eq!(render.input_events.len(), 3); + assert!(matches!( + render.input_events[0], + NoteEvent::NoteOn { + timing: 4, + voice_id: Some(id), + channel: 3, + note: 60, + .. + } if id == note_id as i32 + )); + assert!(matches!( + render.input_events[1], + NoteEvent::PolyTuning { + timing: 4, + voice_id: Some(id), + tuning, + .. + } if id == note_id as i32 && (tuning - 0.25).abs() < f32::EPSILON + )); + assert!(matches!( + render.input_events[2], + NoteEvent::NoteOff { + timing: 9, + voice_id: Some(id), + channel: 3, + note: 60, + .. + } if id == note_id as i32 + )); + } + + #[repr(C)] + struct PacketCapture { + calls: u32, + count: u32, + timestamps: [u64; 4], + lengths: [u16; 4], + data: [[u8; 3]; 4], + } + + unsafe extern "C" fn capture_packets( + user_data: *mut c_void, + _time_stamp: *const au::AudioTimeStamp, + midi_output_number: au::UInt32, + packet_list: *const c_void, + ) -> au::OSStatus { + assert_eq!(midi_output_number, 0); + let capture = unsafe { &mut *(user_data as *mut PacketCapture) }; + capture.calls += 1; + let base = packet_list as *const u8; + let count = unsafe { std::ptr::read_unaligned(base as *const u32) }; + let mut offset = 4usize; + for _ in 0..count { + let idx = capture.count as usize; + capture.timestamps[idx] = + unsafe { std::ptr::read_unaligned(base.add(offset) as *const u64) }; + let length = unsafe { std::ptr::read_unaligned(base.add(offset + 8) as *const u16) }; + capture.lengths[idx] = length; + let bytes = + unsafe { std::slice::from_raw_parts(base.add(offset + 10), length as usize) }; + capture.data[idx][..bytes.len()].copy_from_slice(bytes); + capture.count += 1; + let next = offset + 10 + length as usize; + #[cfg(target_arch = "aarch64")] + { + offset = (next + 3) & !3; + } + #[cfg(not(target_arch = "aarch64"))] + { + offset = next; + } + } + au::noErr + } + + #[test] + fn midi_output_callback_receives_sample_offset_packets() { + let mut render = MidiRenderState::::new(); + render.output_events.push_back(NoteEvent::NoteOn { + timing: 3, + voice_id: None, + channel: 2, + note: 64, + velocity: 1.0, + }); + render + .output_events + .push_back(NoteEvent::MidiProgramChange { + timing: 7, + channel: 2, + program: 12, + }); + let mut capture = PacketCapture { + calls: 0, + count: 0, + timestamps: [0; 4], + lengths: [0; 4], + data: [[0; 3]; 4], + }; + let callback = AuMidiOutputCallbackStruct { + callback: Some(capture_packets), + user_data: &mut capture as *mut PacketCapture as *mut c_void, + }; + + assert_eq!( + unsafe { render.flush_output(Some(callback), std::ptr::null(), 16) }, + au::noErr + ); + assert_eq!(capture.calls, 1); + assert_eq!(capture.count, 2); + assert_eq!(capture.timestamps[..2], [3, 7]); + assert_eq!(capture.lengths[..2], [3, 2]); + assert_eq!(capture.data[0], [0x92, 64, 127]); + assert_eq!(capture.data[1][..2], [0xc2, 12]); + } + + #[test] + fn midi_output_callback_replacement_never_blocks_or_tears_render_loads() { + use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; + use std::thread; + + const UPDATE_COUNT: usize = 4096; + let slot = Arc::new(MidiOutputCallbackSlot::new()); + slot.store(Some(AuMidiOutputCallbackStruct { + callback: Some(capture_packets), + user_data: 1usize as *mut c_void, + })) + .unwrap(); + + let writer_slot = slot.clone(); + let writer_done = Arc::new(AtomicBool::new(false)); + let writer_done_clone = writer_done.clone(); + let writer = thread::spawn(move || { + for value in 2..=UPDATE_COUNT { + writer_slot + .store(Some(AuMidiOutputCallbackStruct { + callback: Some(capture_packets), + user_data: value as *mut c_void, + })) + .unwrap(); + } + writer_done_clone.store(true, AtomicOrdering::Release); + }); + + while !writer_done.load(AtomicOrdering::Acquire) { + let callback = slot.load().expect("an installed callback disappeared"); + assert!(callback.callback.is_some()); + let value = callback.user_data as usize; + assert!((1..=UPDATE_COUNT).contains(&value)); + thread::yield_now(); + } + writer.join().unwrap(); + + assert_eq!(slot.load().unwrap().user_data as usize, UPDATE_COUNT); + slot.store(None).unwrap(); + assert!(slot.load().is_none()); + } +} diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 6e94a457c..924210b73 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -29,9 +29,10 @@ //! only ever touched from `render()`. AU guarantees render is not //! re-entered, so a `&mut` borrow inside that scope is sound. //! -//! 3. **main↔audio shared state** — `input_callback`. `Mutex>`; -//! main thread updates rarely, audio thread snapshots into a local `Copy` -//! at the top of `render()`. The mutex is held only for the snapshot. +//! 3. **main↔audio shared state** — existing audio-input wiring is protected +//! by a mutex and snapshotted into a local `Copy`. MIDI output callbacks +//! use an atomic pointer to immutable, lifetime-stable records so their +//! render path never blocks. use std::any::Any; use std::cell::UnsafeCell; @@ -40,10 +41,11 @@ use std::marker::PhantomData; use std::mem; use std::num::NonZeroU32; use std::ptr; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use au_sys as au; +use core_foundation::array::CFArray; use core_foundation::base::{CFType, TCFType}; use core_foundation::data::CFData; use core_foundation::dictionary::CFDictionary; @@ -56,11 +58,17 @@ use crate::editor::Editor; use crate::params::internals::ParamPtr; use crate::params::{ParamFlags, Params}; use crate::plugin::au::AuPlugin; -use crate::prelude::{AudioIOLayout, AuxiliaryBuffers, BufferConfig, ProcessMode}; +use crate::prelude::{ + AudioIOLayout, AuxiliaryBuffers, BufferConfig, MidiConfig, ProcessMode, ProcessStatus, +}; use crate::wrapper::state::{self, PluginState}; -use super::context::{AUParameter, AUParameterListenerNotify, AuGuiContextInner, AuInitContext, AuProcessContext, ContextSink}; +use super::context::{ + AUParameter, AUParameterListenerNotify, AuGuiContextInner, AuInitContext, AuProcessContext, + ContextSink, +}; use super::factory::fourcc; +use super::midi; /// Payload of `kAudioUnitProperty_MakeConnection`. /// @@ -161,6 +169,14 @@ pub struct Wrapper { /// Latency reported via `kAudioUnitProperty_Latency`. f64 bits. latency_seconds_bits: AtomicU64, + /// Tail duration reported by the most recent `Plugin::process()` call. + /// Stored as f64 bits so the render thread can update it lock-free. + tail_seconds_bits: AtomicU64, + + /// OSStatus returned by the most recent failed render, or `noErr` after a + /// successful render. + last_render_error: AtomicI32, + /// Whether `Plugin::initialize()` has run successfully since the last /// `Uninitialize` / first construction. Render is a no-op when false. initialized: AtomicBool, @@ -231,6 +247,15 @@ pub struct Wrapper { /// produce valid audio; the next block picks up the new one. input_connection: Mutex>, + /// Bounded queue populated by the MusicDevice selector calls and drained + /// once at the start of each render block. + midi_input: midi::MidiInputState

, + + /// Host callback installed through `kAudioUnitProperty_MIDIOutputCallback`. + /// Loads from render are lock-free; retired records remain stable until + /// this AudioUnit is destroyed. + midi_output_callback: midi::MidiOutputCallbackSlot, + /// Per-aux-input-port render callbacks (elements 1, 2, … of Input scope). /// Length equals `P::AUDIO_IO_LAYOUTS` max `aux_input_ports.len()`. /// Each `Mutex` is independent so the audio thread can snapshot without @@ -243,7 +268,7 @@ pub struct Wrapper { /// /// `UnsafeCell` because only `render()` touches it after `Initialize`, /// and AU does not re-enter render. - render_state: UnsafeCell, + render_state: UnsafeCell>, /// The plugin's `Editor` instance, if the plugin provides one. /// Created once in `new()` and never replaced. @@ -263,7 +288,7 @@ pub struct Wrapper { /// All audio-thread mutable state. Reused across render calls and grown /// only inside `Initialize` (main thread, before render is allowed). The /// render hot path only writes existing slots — no allocation. -struct RenderState { +struct RenderState { /// Per-channel scratch for input pulled via the host's render callback. /// One inner vec per channel, each pre-sized to `max_frames_per_slice`. input_scratch: Vec>, @@ -295,9 +320,12 @@ struct RenderState { /// Per-aux-port `Buffer<'static>` whose slot vectors are pre-grown in /// `provision`. Slices are cleared after each `render()` call. aux_buffers: Vec>, + + /// Preallocated MIDI input/output queues and MIDIPacketList storage. + midi: midi::MidiRenderState

, } -impl RenderState { +impl RenderState

{ fn new() -> Self { Self { input_scratch: Vec::new(), @@ -306,6 +334,7 @@ impl RenderState { aux_input_scratch: Vec::new(), aux_bl_storages: Vec::new(), aux_buffers: Vec::new(), + midi: midi::MidiRenderState::new(), } } @@ -348,7 +377,8 @@ impl RenderState { for &port_ch in aux_ports { let n_ch = port_ch.get() as usize; // Per-channel scratch frames. - self.aux_input_scratch.push(vec![vec![0.0_f32; max_frames]; n_ch]); + self.aux_input_scratch + .push(vec![vec![0.0_f32; max_frames]; n_ch]); // BufferList backing storage. let words = bl_byte_size(n_ch).div_ceil(mem::size_of::()); self.aux_bl_storages.push(vec![0u64; words]); @@ -408,6 +438,8 @@ const NOTIFY_LATENCY: u32 = 1 << 0; const NOTIFY_STREAM_FORMAT: u32 = 1 << 1; const NOTIFY_BYPASS_EFFECT: u32 = 1 << 2; const NOTIFY_MAX_FRAMES_PER_SLICE: u32 = 1 << 3; +const NOTIFY_TAIL_TIME: u32 = 1 << 4; +const NOTIFY_LAST_RENDER_ERROR: u32 = 1 << 5; /// `Send` + `Sync` justification: /// @@ -463,7 +495,9 @@ impl Wrapper

{ execute_background: Arc::new(|_| {}), execute_gui: Arc::new(|_| {}), }; - let editor = plugin.editor(async_executor).map(|e| Arc::new(Mutex::new(e))); + let editor = plugin + .editor(async_executor) + .map(|e| Arc::new(Mutex::new(e))); // `gui_context_inner` is created here with instance = null; the // real AudioUnit handle is filled in by `open()`. Because @@ -492,6 +526,8 @@ impl Wrapper

{ max_frames_per_slice: AtomicU32::new(1024), n_channels: AtomicU32::new(2), latency_seconds_bits: AtomicU64::new(pack_f64(0.0)), + tail_seconds_bits: AtomicU64::new(pack_f64(0.0)), + last_render_error: AtomicI32::new(au::noErr), initialized: AtomicBool::new(false), bypass: AtomicBool::new(false), bypass_param_idx, @@ -502,6 +538,8 @@ impl Wrapper

{ sink: ContextSink::new(), input_callback: Mutex::new(None), input_connection: Mutex::new(None), + midi_input: midi::MidiInputState::new(), + midi_output_callback: midi::MidiOutputCallbackSlot::new(), aux_input_callbacks: { let n_aux = P::AUDIO_IO_LAYOUTS .iter() @@ -550,6 +588,26 @@ impl Wrapper

{ .store(pack_f64(l), Ordering::Release); } #[inline] + fn tail_seconds(&self) -> f64 { + unpack_f64(self.tail_seconds_bits.load(Ordering::Acquire)) + } + #[inline] + fn set_tail_seconds(&self, seconds: f64) { + let previous = self + .tail_seconds_bits + .swap(pack_f64(seconds), Ordering::AcqRel); + if previous != pack_f64(seconds) { + self.mark_pending(NOTIFY_TAIL_TIME); + } + } + #[inline] + fn set_last_render_error(&self, status: au::OSStatus) { + let previous = self.last_render_error.swap(status, Ordering::AcqRel); + if status != au::noErr && previous != status { + self.mark_pending(NOTIFY_LAST_RENDER_ERROR); + } + } + #[inline] fn n_channels(&self) -> u32 { self.n_channels.load(Ordering::Acquire) } @@ -602,11 +660,20 @@ impl Wrapper

{ fire(au::kAudioUnitProperty_Latency, au::kAudioUnitScope_Global); } if pending & NOTIFY_STREAM_FORMAT != 0 { - fire(au::kAudioUnitProperty_StreamFormat, au::kAudioUnitScope_Output); - fire(au::kAudioUnitProperty_StreamFormat, au::kAudioUnitScope_Input); + fire( + au::kAudioUnitProperty_StreamFormat, + au::kAudioUnitScope_Output, + ); + fire( + au::kAudioUnitProperty_StreamFormat, + au::kAudioUnitScope_Input, + ); } if pending & NOTIFY_BYPASS_EFFECT != 0 { - fire(au::kAudioUnitProperty_BypassEffect, au::kAudioUnitScope_Global); + fire( + au::kAudioUnitProperty_BypassEffect, + au::kAudioUnitScope_Global, + ); } if pending & NOTIFY_MAX_FRAMES_PER_SLICE != 0 { fire( @@ -614,6 +681,15 @@ impl Wrapper

{ au::kAudioUnitScope_Global, ); } + if pending & NOTIFY_TAIL_TIME != 0 { + fire(au::kAudioUnitProperty_TailTime, au::kAudioUnitScope_Global); + } + if pending & NOTIFY_LAST_RENDER_ERROR != 0 { + fire( + au::kAudioUnitProperty_LastRenderError, + au::kAudioUnitScope_Global, + ); + } } /// SAFETY: caller must guarantee no other reference (mut or shared) to @@ -631,7 +707,7 @@ impl Wrapper

{ /// `RenderState` for in-place mutation by the audio thread. #[inline] #[allow(clippy::mut_from_ref)] - unsafe fn render_state_mut(&self) -> &mut RenderState { + unsafe fn render_state_mut(&self) -> &mut RenderState

{ unsafe { &mut *self.render_state.get() } } @@ -653,6 +729,12 @@ impl Wrapper

{ unsafe extern "C" fn close(self_ptr: *mut c_void) -> au::OSStatus { // Drop editor handle before the wrapper is destroyed. let this = unsafe { Self::from_ptr(self_ptr) }; + if this.initialized.swap(false, Ordering::AcqRel) { + // Hosts are allowed to dispose an initialized AudioUnit without a + // preceding Uninitialize call. Keep Plugin's lifecycle balanced. + unsafe { this.plugin_mut() }.deactivate(); + } + this.midi_input.clear(); let instance = this.instance.swap(0, Ordering::AcqRel) as usize as *mut c_void; cocoaui::close_audio_unit_view(instance); this.editor_handle.clear(); @@ -671,9 +753,7 @@ impl Wrapper

{ std::mem::transmute::(Self::uninitialize) }, au::kAudioUnitGetPropertyInfoSelect => unsafe { - std::mem::transmute::( - Self::get_property_info, - ) + std::mem::transmute::(Self::get_property_info) }, au::kAudioUnitGetPropertySelect => unsafe { std::mem::transmute::(Self::get_property) @@ -703,6 +783,24 @@ impl Wrapper

{ Self::remove_property_listener_with_user_data, ) }, + midi::MUSIC_DEVICE_MIDI_EVENT_SELECT if P::MIDI_INPUT >= MidiConfig::Basic => unsafe { + std::mem::transmute::( + Self::music_device_midi_event, + ) + }, + midi::MUSIC_DEVICE_SYS_EX_SELECT if P::MIDI_INPUT >= MidiConfig::Basic => unsafe { + std::mem::transmute::(Self::music_device_sys_ex) + }, + midi::MUSIC_DEVICE_START_NOTE_SELECT if P::MIDI_INPUT >= MidiConfig::Basic => unsafe { + std::mem::transmute::( + Self::music_device_start_note, + ) + }, + midi::MUSIC_DEVICE_STOP_NOTE_SELECT if P::MIDI_INPUT >= MidiConfig::Basic => unsafe { + std::mem::transmute::( + Self::music_device_stop_note, + ) + }, _ => return None, }; Some(method) @@ -716,18 +814,11 @@ impl Wrapper

{ let this = unsafe { Self::from_ptr(self_ptr) }; let n_ch = this.n_channels().max(1); - let chans = NonZeroU32::new(n_ch); - // Pick the best-matching layout for the current channel count. Prefer a - // layout whose main_input_channels matches; fall back to const_default. - let selected_layout = P::AUDIO_IO_LAYOUTS - .iter() - .find(|l| l.main_input_channels == chans && l.main_output_channels == chans) - .copied() - .unwrap_or(AudioIOLayout { - main_input_channels: chans, - main_output_channels: chans, - ..AudioIOLayout::const_default() - }); + let chans = NonZeroU32::new(n_ch).expect("n_ch is clamped to at least one"); + let selected_layout = match layout_for_output::

(chans) { + Some(layout) => *layout, + None => return au::kAudioUnitErr_FormatNotSupported, + }; let max_frames = this.max_frames_per_slice(); let sr = this.sample_rate(); let buffer_config = BufferConfig { @@ -758,7 +849,15 @@ impl Wrapper

{ // Provision the audio-thread render state so the hot path is // allocation-free. SAFETY: render is serialised vs. Initialize. let render_state = unsafe { this.render_state_mut() }; - render_state.provision(n_ch as usize, max_frames as usize, selected_layout.aux_input_ports); + render_state.provision( + n_ch as usize, + max_frames as usize, + selected_layout.aux_input_ports, + ); + render_state.midi.clear(); + this.midi_input.clear(); + this.set_tail_seconds(0.0); + this.set_last_render_error(au::noErr); let latency = this.sink.latency_samples.load(Ordering::Relaxed); if latency > 0 && sr > 0.0 { @@ -785,6 +884,8 @@ impl Wrapper

{ // SAFETY: AU forbids Uninitialize concurrent with Render. unsafe { this.plugin_mut() }.deactivate(); } + this.midi_input.clear(); + unsafe { this.render_state_mut() }.midi.clear(); au::noErr } @@ -796,9 +897,61 @@ impl Wrapper

{ let this = unsafe { Self::from_ptr(self_ptr) }; // SAFETY: AU calls Reset on the main thread, serialised against render. unsafe { this.plugin_mut() }.reset(); + this.midi_input.clear(); + unsafe { this.render_state_mut() }.midi.clear(); + this.set_tail_seconds(0.0); au::noErr } + unsafe extern "C" fn music_device_midi_event( + self_ptr: *mut c_void, + status: au::UInt32, + data_1: au::UInt32, + data_2: au::UInt32, + offset: au::UInt32, + ) -> au::OSStatus { + unsafe { Self::from_ptr(self_ptr) } + .midi_input + .push_midi_event(status, data_1, data_2, offset) + } + + unsafe extern "C" fn music_device_sys_ex( + self_ptr: *mut c_void, + data: *const u8, + length: au::UInt32, + ) -> au::OSStatus { + unsafe { Self::from_ptr(self_ptr) } + .midi_input + .push_sysex(data, length) + } + + unsafe extern "C" fn music_device_start_note( + self_ptr: *mut c_void, + _instrument: au::UInt32, + group: au::UInt32, + out_note_id: *mut au::UInt32, + offset: au::UInt32, + params: *const midi::MusicDeviceNoteParams, + ) -> au::OSStatus { + unsafe { Self::from_ptr(self_ptr) }.midi_input.start_note( + group, + out_note_id, + offset, + params, + ) + } + + unsafe extern "C" fn music_device_stop_note( + self_ptr: *mut c_void, + group: au::UInt32, + note_id: au::UInt32, + offset: au::UInt32, + ) -> au::OSStatus { + unsafe { Self::from_ptr(self_ptr) } + .midi_input + .stop_note(group, note_id, offset) + } + fn get_class_info(&self) -> *mut c_void { let state = unsafe { state::serialize_object::

( @@ -898,7 +1051,7 @@ impl Wrapper

{ self_ptr: *mut c_void, id: au::AudioUnitPropertyID, scope: au::AudioUnitScope, - _element: au::AudioUnitElement, + element: au::AudioUnitElement, out_data_size: *mut au::UInt32, out_writable: *mut au::Boolean, ) -> au::OSStatus { @@ -918,15 +1071,18 @@ impl Wrapper

{ match id { au::kAudioUnitProperty_SampleRate - if scope == au::kAudioUnitScope_Input - || scope == au::kAudioUnitScope_Output => + if scope == au::kAudioUnitScope_Input || scope == au::kAudioUnitScope_Output => { respond(std::mem::size_of::() as u32, true) } - au::kAudioUnitProperty_StreamFormat => respond( - std::mem::size_of::() as u32, - true, - ), + au::kAudioUnitProperty_StreamFormat + if bus_channel_count::

(this.n_channels(), scope, element).is_some() => + { + respond( + std::mem::size_of::() as u32, + true, + ) + } au::kAudioUnitProperty_ElementCount => { respond(std::mem::size_of::() as u32, false) } @@ -936,9 +1092,7 @@ impl Wrapper

{ au::kAudioUnitProperty_TailTime if scope == au::kAudioUnitScope_Global => { respond(std::mem::size_of::() as u32, false) } - au::kAudioUnitProperty_MaximumFramesPerSlice - if scope == au::kAudioUnitScope_Global => - { + au::kAudioUnitProperty_MaximumFramesPerSlice if scope == au::kAudioUnitScope_Global => { respond(std::mem::size_of::() as u32, true) } au::kAudioUnitProperty_ParameterList if scope == au::kAudioUnitScope_Global => { @@ -948,18 +1102,22 @@ impl Wrapper

{ false, ) } - au::kAudioUnitProperty_ParameterInfo if scope == au::kAudioUnitScope_Global => { + au::kAudioUnitProperty_ParameterInfo if scope == au::kAudioUnitScope_Global => respond( + std::mem::size_of::() as u32, + false, + ), + au::kAudioUnitProperty_SupportedNumChannels if scope == au::kAudioUnitScope_Global => { respond( - std::mem::size_of::() as u32, + (P::AUDIO_IO_LAYOUTS.len() * std::mem::size_of::()) as u32, false, ) } - au::kAudioUnitProperty_SupportedNumChannels - if scope == au::kAudioUnitScope_Global => + au::kAudioUnitProperty_MakeConnection + if scope == au::kAudioUnitScope_Input + && element == 0 + && current_layout::

(this.n_channels()) + .is_some_and(|layout| layout.main_input_channels.is_some()) => { - respond(std::mem::size_of::() as u32, false) - } - au::kAudioUnitProperty_MakeConnection if scope == au::kAudioUnitScope_Input => { respond(std::mem::size_of::() as u32, true) } au::kAudioUnitProperty_BypassEffect if scope == au::kAudioUnitScope_Global => { @@ -968,7 +1126,10 @@ impl Wrapper

{ au::kAudioUnitProperty_LastRenderError if scope == au::kAudioUnitScope_Global => { respond(std::mem::size_of::() as u32, false) } - au::kAudioUnitProperty_SetRenderCallback if scope == au::kAudioUnitScope_Input => { + au::kAudioUnitProperty_SetRenderCallback + if scope == au::kAudioUnitScope_Input + && bus_channel_count::

(this.n_channels(), scope, element).is_some() => + { respond( std::mem::size_of::() as u32, true, @@ -983,6 +1144,24 @@ impl Wrapper

{ au::kAudioUnitProperty_HostCallbacks if scope == au::kAudioUnitScope_Global => { respond(std::mem::size_of::() as u32, true) } + au::kAudioUnitProperty_MIDIOutputCallbackInfo + if scope == au::kAudioUnitScope_Global && P::MIDI_OUTPUT >= MidiConfig::Basic => + { + respond(std::mem::size_of::<*mut c_void>() as u32, false) + } + au::kAudioUnitProperty_MIDIOutputCallback + if scope == au::kAudioUnitScope_Global && P::MIDI_OUTPUT >= MidiConfig::Basic => + { + respond( + std::mem::size_of::() as u32, + true, + ) + } + midi::MUSIC_DEVICE_PROPERTY_SUPPORTS_START_STOP_NOTE + if scope == au::kAudioUnitScope_Global && P::MIDI_INPUT >= MidiConfig::Basic => + { + respond(std::mem::size_of::() as u32, false) + } au::kAudioUnitProperty_CocoaUI if scope == au::kAudioUnitScope_Global => { if this.editor.is_some() { respond(std::mem::size_of::() as u32, false) @@ -1019,11 +1198,17 @@ impl Wrapper

{ return au::kAudioUnitErr_InvalidParameter; } - match id { - au::kAudioUnitProperty_SampleRate => { - if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { + macro_rules! require_output { + ($ty:ty) => { + if (unsafe { *io_data_size } as usize) < std::mem::size_of::<$ty>() { return au::kAudioUnitErr_InvalidPropertyValue; } + }; + } + + match id { + au::kAudioUnitProperty_SampleRate => { + require_output!(au::Float64); unsafe { *(out_data as *mut au::Float64) = this.sample_rate(); *io_data_size = std::mem::size_of::() as u32; @@ -1031,17 +1216,19 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_ElementCount => { - // Input scope: element 0 = main, elements 1+ = aux inputs. + require_output!(au::UInt32); + // Input scope: optional main input followed by aux inputs. // Output scope: always 1 (aux outputs not yet implemented). let n_ch = this.n_channels().max(1); let chans = NonZeroU32::new(n_ch); - let n_aux_inputs = P::AUDIO_IO_LAYOUTS - .iter() - .find(|l| l.main_input_channels == chans && l.main_output_channels == chans) - .map(|l| l.aux_input_ports.len()) - .unwrap_or(0); + let layout = chans.and_then(layout_for_output::

); let count: au::UInt32 = match scope { - au::kAudioUnitScope_Input => 1 + n_aux_inputs as u32, + au::kAudioUnitScope_Input => layout + .map(|layout| { + u32::from(layout.main_input_channels.is_some()) + + layout.aux_input_ports.len() as u32 + }) + .unwrap_or(0), au::kAudioUnitScope_Output => 1, au::kAudioUnitScope_Global => 1, _ => 0, @@ -1053,6 +1240,7 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_Latency if scope == au::kAudioUnitScope_Global => { + require_output!(au::Float64); unsafe { *(out_data as *mut au::Float64) = this.latency_seconds(); *io_data_size = std::mem::size_of::() as u32; @@ -1060,13 +1248,15 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_TailTime if scope == au::kAudioUnitScope_Global => { + require_output!(au::Float64); unsafe { - *(out_data as *mut au::Float64) = 0.0; + *(out_data as *mut au::Float64) = this.tail_seconds(); *io_data_size = std::mem::size_of::() as u32; } au::noErr } au::kAudioUnitProperty_MaximumFramesPerSlice => { + require_output!(au::UInt32); unsafe { *(out_data as *mut au::UInt32) = this.max_frames_per_slice(); *io_data_size = std::mem::size_of::() as u32; @@ -1074,11 +1264,11 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_StreamFormat => { - if (unsafe { *io_data_size } as usize) - < std::mem::size_of::() - { - return au::kAudioUnitErr_InvalidPropertyValue; - } + require_output!(au::AudioStreamBasicDescription); + let channels = match bus_channel_count::

(this.n_channels(), scope, element) { + Some(channels) => channels, + None => return au::kAudioUnitErr_InvalidElement, + }; let asbd = au::AudioStreamBasicDescription { mSampleRate: this.sample_rate(), mFormatID: au::kAudioFormatLinearPCM, @@ -1088,14 +1278,13 @@ impl Wrapper

{ mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, - mChannelsPerFrame: this.n_channels(), + mChannelsPerFrame: channels, mBitsPerChannel: 32, mReserved: 0, }; unsafe { *(out_data as *mut au::AudioStreamBasicDescription) = asbd; - *io_data_size = - std::mem::size_of::() as u32; + *io_data_size = std::mem::size_of::() as u32; } au::noErr } @@ -1134,43 +1323,36 @@ impl Wrapper

{ } au::noErr } - au::kAudioUnitProperty_SupportedNumChannels - if scope == au::kAudioUnitScope_Global => - { - if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { + au::kAudioUnitProperty_SupportedNumChannels if scope == au::kAudioUnitScope_Global => { + let needed = P::AUDIO_IO_LAYOUTS.len() * std::mem::size_of::(); + if (unsafe { *io_data_size } as usize) < needed { return au::kAudioUnitErr_InvalidPropertyValue; } - // Report the first declared layout's main channel count. - // If multiple layouts are declared we currently only expose - // one entry; auval accepts this as a conservative answer. - let info = match P::AUDIO_IO_LAYOUTS.iter().next() { - Some(layout) => { - let in_ch = layout - .main_input_channels - .map(|n| n.get() as i16) - .unwrap_or(0); - let out_ch = layout - .main_output_channels - .map(|n| n.get() as i16) - .unwrap_or(0); - au::AUChannelInfo { - inChannels: in_ch, - outChannels: out_ch, - } + let dst = out_data as *mut au::AUChannelInfo; + for (idx, layout) in P::AUDIO_IO_LAYOUTS.iter().enumerate() { + unsafe { + *dst.add(idx) = au::AUChannelInfo { + inChannels: layout + .main_input_channels + .map(|n| n.get() as i16) + .unwrap_or(0), + outChannels: layout + .main_output_channels + .map(|n| n.get() as i16) + .unwrap_or(0), + }; } - None => au::AUChannelInfo { - inChannels: -1, - outChannels: -1, - }, - }; - unsafe { - *(out_data as *mut au::AUChannelInfo) = info; - *io_data_size = std::mem::size_of::() as u32; } + unsafe { *io_data_size = needed as u32 }; au::noErr } au::kAudioUnitProperty_BypassEffect if scope == au::kAudioUnitScope_Global => { - let on = if this.bypass.load(Ordering::Acquire) { 1 } else { 0 }; + require_output!(au::UInt32); + let on = if this.bypass.load(Ordering::Acquire) { + 1 + } else { + 0 + }; unsafe { *(out_data as *mut au::UInt32) = on; *io_data_size = std::mem::size_of::() as u32; @@ -1178,13 +1360,16 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_LastRenderError if scope == au::kAudioUnitScope_Global => { + require_output!(au::OSStatus); unsafe { - *(out_data as *mut au::OSStatus) = au::noErr; + *(out_data as *mut au::OSStatus) = + this.last_render_error.load(Ordering::Acquire); *io_data_size = std::mem::size_of::() as u32; } au::noErr } au::kAudioUnitProperty_InPlaceProcessing => { + require_output!(au::UInt32); unsafe { *(out_data as *mut au::UInt32) = 1; *io_data_size = std::mem::size_of::() as u32; @@ -1192,9 +1377,7 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_ClassInfo if scope == au::kAudioUnitScope_Global => { - if (unsafe { *io_data_size } as usize) < std::mem::size_of::<*mut c_void>() { - return au::kAudioUnitErr_InvalidPropertyValue; - } + require_output!(*mut c_void); let dict = this.get_class_info(); unsafe { *(out_data as *mut *mut c_void) = dict; @@ -1206,7 +1389,8 @@ impl Wrapper

{ if this.editor.is_none() { return au::kAudioUnitErr_InvalidProperty; } - if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { + if (unsafe { *io_data_size } as usize) < std::mem::size_of::() + { return au::kAudioUnitErr_InvalidPropertyValue; } // Register (or look up) the per-type ObjC view factory class and @@ -1244,6 +1428,30 @@ impl Wrapper

{ } au::noErr } + au::kAudioUnitProperty_MIDIOutputCallbackInfo + if scope == au::kAudioUnitScope_Global && P::MIDI_OUTPUT >= MidiConfig::Basic => + { + require_output!(*mut c_void); + let names: CFArray = + CFArray::from_CFTypes(&[CFString::from_static_string("MIDI Out")]); + let names_ref = names.as_concrete_TypeRef(); + std::mem::forget(names); + unsafe { + *(out_data as *mut *mut c_void) = names_ref as *mut c_void; + *io_data_size = std::mem::size_of::<*mut c_void>() as u32; + } + au::noErr + } + midi::MUSIC_DEVICE_PROPERTY_SUPPORTS_START_STOP_NOTE + if scope == au::kAudioUnitScope_Global && P::MIDI_INPUT >= MidiConfig::Basic => + { + require_output!(au::UInt32); + unsafe { + *(out_data as *mut au::UInt32) = 1; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -1306,6 +1514,9 @@ impl Wrapper

{ if asbd.mChannelsPerFrame == 0 { return au::kAudioUnitErr_InvalidPropertyValue; } + if !(asbd.mSampleRate > 0.0 && asbd.mSampleRate.is_finite()) { + return au::kAudioUnitErr_InvalidPropertyValue; + } // Reject non-PCM / non-float / interleaved — we only ever // advertise non-interleaved 32-bit float in get_property. if asbd.mFormatID != au::kAudioFormatLinearPCM @@ -1317,16 +1528,33 @@ impl Wrapper

{ if asbd.mBitsPerChannel != 32 { return au::kAudioUnitErr_FormatNotSupported; } - // Match the requested channel count against P::AUDIO_IO_LAYOUTS. - // For an effect (in_ch == out_ch) we look for a layout where - // both main_input and main_output match. let req_ch = asbd.mChannelsPerFrame; - if !layout_supports::

(req_ch) { - return au::kAudioUnitErr_FormatNotSupported; + match scope { + au::kAudioUnitScope_Output if element == 0 => { + let req = NonZeroU32::new(req_ch).expect("channel count checked above"); + if layout_for_output::

(req).is_none() { + return au::kAudioUnitErr_FormatNotSupported; + } + this.n_channels.store(req_ch, Ordering::Release); + } + au::kAudioUnitScope_Input => { + let expected = match bus_channel_count::

( + this.n_channels(), + au::kAudioUnitScope_Input, + element, + ) { + Some(expected) => expected, + None => return au::kAudioUnitErr_InvalidElement, + }; + if expected != req_ch { + return au::kAudioUnitErr_FormatNotSupported; + } + } + au::kAudioUnitScope_Output => return au::kAudioUnitErr_InvalidElement, + _ => return au::kAudioUnitErr_InvalidScope, } this.set_sample_rate(asbd.mSampleRate); - this.n_channels.store(req_ch, Ordering::Release); this.mark_pending(NOTIFY_STREAM_FORMAT); au::noErr } @@ -1375,6 +1603,12 @@ impl Wrapper

{ if element != 0 { return au::kAudioUnitErr_InvalidElement; } + let has_main_input = current_layout::

(this.n_channels()) + .map(|layout| layout.main_input_channels.is_some()) + .unwrap_or(false); + if !has_main_input { + return au::kAudioUnitErr_InvalidElement; + } let conn = unsafe { *(in_data as *const AudioUnitConnection) }; // `conn.dest_input_number` is deliberately not consulted: AU's // convention is that the property's element *is* the @@ -1400,11 +1634,20 @@ impl Wrapper

{ } au::noErr } - au::kAudioUnitProperty_SetRenderCallback => { + au::kAudioUnitProperty_SetRenderCallback if scope == au::kAudioUnitScope_Input => { payload!(au::AURenderCallbackStruct); let cb = unsafe { *(in_data as *const au::AURenderCallbackStruct) }; - let new_cb = if cb.inputProc.is_some() { Some(cb) } else { None }; - if element == 0 { + let new_cb = if cb.inputProc.is_some() { + Some(cb) + } else { + None + }; + let layout = match current_layout::

(this.n_channels()) { + Some(layout) => layout, + None => return au::kAudioUnitErr_FormatNotSupported, + }; + let aux_base = u32::from(layout.main_input_channels.is_some()); + if aux_base == 1 && element == 0 { if let Ok(mut guard) = this.input_callback.lock() { *guard = new_cb; } @@ -1414,12 +1657,19 @@ impl Wrapper

{ *guard = None; } } else { - // elements 1+ are aux inputs - let aux_idx = (element - 1) as usize; + if element < aux_base { + return au::kAudioUnitErr_InvalidElement; + } + let aux_idx = (element - aux_base) as usize; + if aux_idx >= layout.aux_input_ports.len() { + return au::kAudioUnitErr_InvalidElement; + } if let Some(slot) = this.aux_input_callbacks.get(aux_idx) { if let Ok(mut guard) = slot.lock() { *guard = new_cb; } + } else { + return au::kAudioUnitErr_InvalidElement; } } au::noErr @@ -1438,6 +1688,22 @@ impl Wrapper

{ } au::noErr } + au::kAudioUnitProperty_MIDIOutputCallback + if scope == au::kAudioUnitScope_Global && P::MIDI_OUTPUT >= MidiConfig::Basic => + { + payload!(midi::AuMidiOutputCallbackStruct); + let callback = + unsafe { ptr::read(in_data as *const midi::AuMidiOutputCallbackStruct) }; + if this + .midi_output_callback + .store(callback.callback.map(|_| callback)) + .is_ok() + { + au::noErr + } else { + au::kAudioUnitErr_CannotDoInCurrentContext + } + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -1563,6 +1829,7 @@ impl Wrapper

{ let this = unsafe { Self::from_ptr(self_ptr) }; if io_data.is_null() { + this.set_last_render_error(au::kAudioUnitErr_InvalidParameter); return au::kAudioUnitErr_InvalidParameter; } @@ -1572,6 +1839,22 @@ impl Wrapper

{ } let n_frames = in_number_frames as usize; + if in_number_frames > this.max_frames_per_slice() { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + this.set_last_render_error(au::kAudioUnitErr_TooManyFramesToProcess); + return au::kAudioUnitErr_TooManyFramesToProcess; + } + + let layout = match current_layout::

(this.n_channels()) { + Some(layout) => layout, + None => { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + this.set_last_render_error(au::kAudioUnitErr_FormatNotSupported); + return au::kAudioUnitErr_FormatNotSupported; + } + }; + let has_main_input = layout.main_input_channels.is_some(); + let aux_element_base = u32::from(has_main_input); // Snapshot the input callback under the mutex (cheap struct copy). // Released immediately so main-thread updates don't block render @@ -1579,19 +1862,22 @@ impl Wrapper

{ // The two wirings are kept mutually exclusive when the host sets them, // so at most one of these is ever populated; the callback is preferred // if both somehow are. - let input_source = this - .input_callback - .lock() - .ok() - .and_then(|g| *g) - .map(InputSource::Callback) - .or_else(|| { - this.input_connection + let input_source = has_main_input + .then(|| { + this.input_callback .lock() .ok() .and_then(|g| *g) - .map(InputSource::Connection) - }); + .map(InputSource::Callback) + .or_else(|| { + this.input_connection + .lock() + .ok() + .and_then(|g| *g) + .map(InputSource::Connection) + }) + }) + .flatten(); // SAFETY: render is not re-entered; we are the sole owner of // RenderState for the duration of this call. @@ -1615,8 +1901,7 @@ impl Wrapper

{ // The N-tuple of AudioBuffer entries lives at the natural // C `mBuffers` offset, which the compiler computes // accounting for any padding after `mNumberBuffers`. - let header_offset = - mem::offset_of!(au::AudioBufferList, mBuffers); + let header_offset = mem::offset_of!(au::AudioBufferList, mBuffers); let buffers_ptr = unsafe { (rs.bl_storage.as_mut_ptr() as *mut u8).add(header_offset) as *mut au::AudioBuffer @@ -1626,8 +1911,7 @@ impl Wrapper

{ unsafe { *buffers_ptr.add(ch) = au::AudioBuffer { mNumberChannels: 1, - mDataByteSize: (n_frames * mem::size_of::()) - as au::UInt32, + mDataByteSize: (n_frames * mem::size_of::()) as au::UInt32, mData: scratch_ptr as *mut c_void, }; } @@ -1671,7 +1955,11 @@ impl Wrapper

{ ) }, }; - if status == au::noErr { + if status != au::noErr { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + this.set_last_render_error(status); + return status; + } else { pulled_input = true; // The callback is allowed to *replace* the mData pointers // with its own buffers instead of filling the ones we @@ -1689,8 +1977,8 @@ impl Wrapper

{ let src = b.mData as *const f32; let dst = rs.input_scratch[ch].as_mut_ptr(); if !src.is_null() && src != dst as *const f32 { - let frames = n_frames - .min(b.mDataByteSize as usize / mem::size_of::()); + let frames = + n_frames.min(b.mDataByteSize as usize / mem::size_of::()); unsafe { ptr::copy_nonoverlapping(src, dst, frames) }; } } @@ -1711,9 +1999,8 @@ impl Wrapper

{ if buf.mData.is_null() || !buffer_fits_frames(buf, n_frames) { continue; } - let dst = unsafe { - std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) - }; + let dst = + unsafe { std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) }; let src = &rs.input_scratch[i][..n_frames]; dst.copy_from_slice(src); } @@ -1770,10 +2057,7 @@ impl Wrapper

{ slots[i] = &mut []; continue; } - let raw = std::slice::from_raw_parts_mut( - buf.mData as *mut f32, - n_frames, - ); + let raw = std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames); // The slot type carries `'static` because `RenderState` // is itself field-stored; the slice we put here lives // only until we clear it below. This mirrors the @@ -1794,7 +2078,8 @@ impl Wrapper

{ if let Some(beat_and_tempo) = cb.beatAndTempoProc { let mut beat = 0.0; let mut tempo = 0.0; - if unsafe { beat_and_tempo(cb.hostUserData, &mut beat, &mut tempo) } == au::noErr + if unsafe { beat_and_tempo(cb.hostUserData, &mut beat, &mut tempo) } + == au::noErr { transport.pos_beats = Some(beat); transport.tempo = Some(tempo); @@ -1865,6 +2150,7 @@ impl Wrapper

{ // ── 3) Pull aux inputs and wire them into AuxiliaryBuffers. ────── let n_aux = rs.aux_buffers.len(); + let mut upstream_error = au::noErr; for aux_idx in 0..n_aux { let cb_snapshot = this .aux_input_callbacks @@ -1872,8 +2158,8 @@ impl Wrapper

{ .and_then(|m| m.lock().ok().and_then(|g| *g)); let n_ch = rs.aux_input_scratch[aux_idx].len(); - let bl_storage_ok = rs.aux_bl_storages[aux_idx].len() * mem::size_of::() - >= bl_byte_size(n_ch); + let bl_storage_ok = + rs.aux_bl_storages[aux_idx].len() * mem::size_of::() >= bl_byte_size(n_ch); if let Some(cb) = cb_snapshot { if n_ch > 0 && bl_storage_ok { @@ -1901,31 +2187,37 @@ impl Wrapper

{ // Real timestamp, same as the main input: a zeroed one is // a spec violation Logic answers with silence (AUD-831). let mut flags: au::AudioUnitRenderActionFlags = 0; - let proc = cb.inputProc.unwrap(); - // element = aux_idx + 1 (element 0 is main) - let _status = unsafe { + // `SetRenderCallback` stores `None` when `inputProc` is + // absent, but keep the render boundary fail-closed if a + // malformed callback ever reaches this snapshot. + let Some(proc) = cb.inputProc else { + continue; + }; + let status = unsafe { proc( cb.inputProcRefCon, &mut flags, in_time_stamp, - (aux_idx + 1) as au::UInt32, + aux_element_base + aux_idx as au::UInt32, in_number_frames, bl_ptr, ) }; // Same zero-copy contract as the main input: the callback // may have swapped mData to its own buffers (AUD-831). - if _status == au::noErr { + if status == au::noErr { for ch in 0..n_ch { let b = unsafe { &*buffers_ptr.add(ch) }; let src = b.mData as *const f32; let dst = rs.aux_input_scratch[aux_idx][ch].as_mut_ptr(); if !src.is_null() && src != dst as *const f32 { - let frames = n_frames - .min(b.mDataByteSize as usize / mem::size_of::()); + let frames = + n_frames.min(b.mDataByteSize as usize / mem::size_of::()); unsafe { ptr::copy_nonoverlapping(src, dst, frames) }; } } + } else if upstream_error == au::noErr { + upstream_error = status; } // Wire scratch into the aux Buffer's slots. unsafe { @@ -1957,18 +2249,25 @@ impl Wrapper

{ } } - let mut process_ctx = AuProcessContext::

{ - sink: this.sink.clone(), - transport, - _marker: PhantomData, - }; + // Snapshot exactly the MIDI events that were queued before this block + // and order them by sample offset before handing them to the plugin. + this.midi_input.drain_into(&mut rs.midi, in_number_frames); + + if upstream_error != au::noErr { + unsafe { + clear_render_slices(rs); + zero_buffer_list(io_data, in_number_frames); + } + this.set_last_render_error(upstream_error); + return upstream_error; + } // Bypass: skip Plugin::process entirely. Input has already been // copied into io_data above (callback path) or sits there in-place // (host path), so the pass-through is implicit — we just don't run // the plugin's DSP. - if !this.bypass.load(Ordering::Acquire) { + let process_status = if !this.bypass.load(Ordering::Acquire) { // SAFETY: aux_buffers is only accessed here (audio thread, no re-entry). // We cast to `&'static mut [Buffer<'static>]` to satisfy // AuxiliaryBuffers<'_> — the same lifetime-laundering pattern the @@ -1977,35 +2276,67 @@ impl Wrapper

{ let aux_inputs_ptr = rs.aux_buffers.as_mut_ptr(); let aux_inputs_len = rs.aux_buffers.len(); let mut aux = AuxiliaryBuffers { - inputs: unsafe { - std::slice::from_raw_parts_mut(aux_inputs_ptr, aux_inputs_len) - }, + inputs: unsafe { std::slice::from_raw_parts_mut(aux_inputs_ptr, aux_inputs_len) }, outputs: &mut [], }; // SAFETY: render is not concurrent with Initialize/Uninitialize/Reset // and AU does not re-enter render, so this `&mut P` is unique. - let _status = unsafe { this.plugin_mut() }.process( - &mut rs.buffer, - &mut aux, - &mut process_ctx, - ); - } + let midi_state = &mut rs.midi; + let mut process_ctx = AuProcessContext::

{ + sink: this.sink.clone(), + transport, + input_events: &mut midi_state.input_events, + output_events: &mut midi_state.output_events, + _marker: PhantomData, + }; + unsafe { this.plugin_mut() }.process(&mut rs.buffer, &mut aux, &mut process_ctx) + } else { + ProcessStatus::Normal + }; + + let render_status = match process_status { + ProcessStatus::Error(err) => { + nih_debug_assert_failure!("Process error: {}", err); + au::kAudioUnitErr_CannotDoInCurrentContext + } + ProcessStatus::Normal => { + this.set_tail_seconds(0.0); + au::noErr + } + ProcessStatus::Tail(samples) => { + this.set_tail_seconds(if sr > 0.0 { samples as f64 / sr } else { 0.0 }); + au::noErr + } + ProcessStatus::KeepAlive => { + this.set_tail_seconds(f64::INFINITY); + au::noErr + } + }; + + let midi_output_callback = this.midi_output_callback.load(); + let midi_status = if render_status == au::noErr { + unsafe { + rs.midi + .flush_output(midi_output_callback, in_time_stamp, in_number_frames) + } + } else { + rs.midi.output_events.clear(); + au::noErr + }; // Clear the slot slices so the `'static` lifetime can never escape // this render call via a stale `&mut [f32]`. - unsafe { - rs.buffer.set_slices(0, |slots| { - for slot in slots.iter_mut() { - *slot = &mut []; - } - }); - for aux_buf in rs.aux_buffers.iter_mut() { - aux_buf.set_slices(0, |slots| { - for slot in slots.iter_mut() { - *slot = &mut []; - } - }); - } + unsafe { clear_render_slices(rs) }; + + let final_status = if render_status != au::noErr { + render_status + } else { + midi_status + }; + if final_status != au::noErr { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + this.set_last_render_error(final_status); + return final_status; } let latency = this.sink.latency_samples.load(Ordering::Relaxed); @@ -2020,6 +2351,7 @@ impl Wrapper

{ } } + this.set_last_render_error(au::noErr); au::noErr } @@ -2050,9 +2382,7 @@ impl Wrapper

{ let proc_addr = proc as usize; if let Ok(mut guard) = this.listeners.lock() { guard.retain(|l| { - !(l.property_id == id - && (l.proc as usize) == proc_addr - && l.user_data == user_data) + !(l.property_id == id && (l.proc as usize) == proc_addr && l.user_data == user_data) }); } au::noErr @@ -2124,18 +2454,46 @@ fn string_to_cfstring(s: &str) -> au::CFStringRef { ptr as au::CFStringRef } -/// True if the plugin advertises an `AudioIOLayout` whose main I/O matches -/// `req_ch`. We require both `main_input_channels` and `main_output_channels` -/// to match because AU effects use a single channel count for both sides. -fn layout_supports(req_ch: u32) -> bool { - let req = match NonZeroU32::new(req_ch) { - Some(n) => n, - None => return false, - }; - P::AUDIO_IO_LAYOUTS.iter().any(|layout| { - layout.main_input_channels == Some(req) - && layout.main_output_channels == Some(req) - }) +/// Find the declared layout for an AU main output channel count. This also +/// covers instruments/generators whose `main_input_channels` is `None`. +fn layout_for_output(channels: NonZeroU32) -> Option<&'static AudioIOLayout> { + P::AUDIO_IO_LAYOUTS + .iter() + .find(|layout| layout.main_output_channels == Some(channels)) +} + +fn current_layout(output_channels: u32) -> Option<&'static AudioIOLayout> { + NonZeroU32::new(output_channels).and_then(layout_for_output::

) +} + +/// Return the channel count for a concrete AU bus. Input buses are laid out as +/// the optional main input followed by auxiliary inputs; output bus zero is the +/// main output (nih-plug does not expose auxiliary outputs through `Buffer`). +fn bus_channel_count( + output_channels: u32, + scope: au::AudioUnitScope, + element: au::AudioUnitElement, +) -> Option { + let layout = current_layout::

(output_channels)?; + match scope { + au::kAudioUnitScope_Output if element == 0 => { + layout.main_output_channels.map(NonZeroU32::get) + } + au::kAudioUnitScope_Input => { + let has_main = layout.main_input_channels.is_some(); + if has_main && element == 0 { + return layout.main_input_channels.map(NonZeroU32::get); + } + let aux_base = u32::from(has_main); + let aux_idx = element.checked_sub(aux_base)? as usize; + layout + .aux_input_ports + .get(aux_idx) + .copied() + .map(NonZeroU32::get) + } + _ => None, + } } fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { @@ -2176,8 +2534,8 @@ pub use Wrapper as AuWrapper; mod cocoaui { use std::collections::HashMap; use std::ffi::c_void; - use std::sync::{Arc, Mutex, OnceLock}; use std::sync::atomic::Ordering as AtomicOrdering; + use std::sync::{Arc, Mutex, OnceLock}; use au_sys as au; @@ -2194,7 +2552,8 @@ mod cocoaui { fn au_log(msg: &str) { use std::io::Write as _; if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true).append(true) + .create(true) + .append(true) .open("/tmp/nih_plug_au.log") { let _ = writeln!(f, "{}", msg); @@ -2310,7 +2669,10 @@ mod cocoaui { container_ns_view: *mut c_void, handle_slot: *const c_void, ) { - au_log!("[nih-plug AU] cocoaui_close_view: container={:?}", container_ns_view); + au_log!( + "[nih-plug AU] cocoaui_close_view: container={:?}", + container_ns_view + ); if !handle_slot.is_null() { unsafe { (*(handle_slot as *const GuiHandleSlot)).clear() }; } @@ -2361,7 +2723,10 @@ mod cocoaui { .lock() .expect("AU CocoaUI pending-spawn mutex poisoned") .insert(key, template); - au_log!("[nih-plug AU] cocoaui_view_info: spawn template stored for AU={:#x}", key); + au_log!( + "[nih-plug AU] cocoaui_view_info: spawn template stored for AU={:#x}", + key + ); let bundle_url_ref = match unsafe { bundle_cf_url() } { Ok(r) => r, @@ -2387,7 +2752,11 @@ mod cocoaui { return None; } - au_log!("[nih-plug AU] cocoaui_view_info: class={} bundle={:?}", VIEW_CLASS_NAME, bundle_url_ref); + au_log!( + "[nih-plug AU] cocoaui_view_info: class={} bundle={:?}", + VIEW_CLASS_NAME, + bundle_url_ref + ); Some(au::AUCocoaViewInfo { mCocoaAUViewBundleLocation: bundle_url_ref, mCocoaAUViewClass: [class_name_ref], @@ -2430,10 +2799,14 @@ mod cocoaui { // Foo.component/Contents/MacOS/Foo → Foo.component/ let bundle_path = std::path::Path::new(dylib_path) - .parent().ok_or(())? // MacOS/ - .parent().ok_or(())? // Contents/ - .parent().ok_or(())? // Foo.component/ - .to_str().ok_or(())?; + .parent() + .ok_or(())? // MacOS/ + .parent() + .ok_or(())? // Contents/ + .parent() + .ok_or(())? // Foo.component/ + .to_str() + .ok_or(())?; let path_bytes = bundle_path.as_bytes(); let cf_url = unsafe { @@ -2489,6 +2862,26 @@ fn buffer_fits_frames(buf: &au::AudioBuffer, n_samples: usize) -> bool { reported_bytes == 0 || reported_bytes / mem::size_of::() >= n_samples } +/// Clear every host-backed slice before leaving `render()`. `RenderState` +/// stores these with a widened lifetime, so no early-return path may retain a +/// pointer after the host's AudioBufferList goes out of scope. +unsafe fn clear_render_slices(state: &mut RenderState

) { + unsafe { + state.buffer.set_slices(0, |slots| { + for slot in slots.iter_mut() { + *slot = &mut []; + } + }); + for aux_buffer in state.aux_buffers.iter_mut() { + aux_buffer.set_slices(0, |slots| { + for slot in slots.iter_mut() { + *slot = &mut []; + } + }); + } + } +} + #[cfg(test)] mod buffer_list_tests { use super::*; @@ -2519,10 +2912,7 @@ mod buffer_list_tests { let header_offset = mem::offset_of!(au::AudioBufferList, mBuffers); let buffers_ptr = (bl_ptr as *mut u8).add(header_offset) as *mut au::AudioBuffer; - *buffers_ptr.add(0) = buffer( - FRAMES * mem::size_of::(), - well_formed.as_mut_ptr(), - ); + *buffers_ptr.add(0) = buffer(FRAMES * mem::size_of::(), well_formed.as_mut_ptr()); // Only claims a single sample's worth of storage *buffers_ptr.add(1) = buffer(mem::size_of::(), undersized.as_mut_ptr()); // A null payload must be skipped entirely @@ -2553,12 +2943,178 @@ mod buffer_list_tests { #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use crate::prelude::*; + + #[derive(Default)] + struct InstrumentParams; + + unsafe impl Params for InstrumentParams { + fn param_map(&self) -> Vec<(String, ParamPtr, String)> { + Vec::new() + } + } + + struct TestInstrument { + params: Arc, + } + + impl Default for TestInstrument { + fn default() -> Self { + Self { + params: Arc::new(InstrumentParams), + } + } + } + + impl Plugin for TestInstrument { + const NAME: &'static str = "AU Instrument Test"; + const VENDOR: &'static str = "NIH-plug"; + const URL: &'static str = "https://github.com/robbert-vdh/nih-plug"; + const EMAIL: &'static str = "test@example.com"; + const VERSION: &'static str = "0.0.0"; + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[ + AudioIOLayout { + main_input_channels: None, + main_output_channels: NonZeroU32::new(2), + ..AudioIOLayout::const_default() + }, + AudioIOLayout { + main_input_channels: None, + main_output_channels: NonZeroU32::new(1), + ..AudioIOLayout::const_default() + }, + ]; + const MIDI_INPUT: MidiConfig = MidiConfig::Basic; + + type SysExMessage = (); + type BackgroundTask = (); + + fn params(&self) -> Arc { + self.params.clone() + } + + fn process( + &mut self, + _buffer: &mut Buffer, + _aux: &mut AuxiliaryBuffers, + _context: &mut impl ProcessContext, + ) -> ProcessStatus { + ProcessStatus::KeepAlive + } + } + + impl AuPlugin for TestInstrument { + const AU_TYPE: [u8; 4] = *b"aumu"; + const AU_SUBTYPE: [u8; 4] = *b"TstI"; + const AU_MANUFACTURER: [u8; 4] = *b"Test"; + } + + #[test] + fn instrument_has_no_audio_input_bus_and_exposes_music_device_selectors() { + let wrapper = Wrapper::::new() as *mut c_void; + let mut count = u32::MAX; + let mut size = std::mem::size_of::() as u32; + + assert_eq!( + unsafe { + Wrapper::::get_property( + wrapper, + au::kAudioUnitProperty_ElementCount, + au::kAudioUnitScope_Input, + 0, + &mut count as *mut u32 as *mut c_void, + &mut size, + ) + }, + au::noErr + ); + assert_eq!(count, 0); + assert_eq!( + bus_channel_count::(2, au::kAudioUnitScope_Output, 0), + Some(2) + ); + assert_eq!( + bus_channel_count::(2, au::kAudioUnitScope_Input, 0), + None + ); + assert!( + unsafe { Wrapper::::lookup(midi::MUSIC_DEVICE_MIDI_EVENT_SELECT) } + .is_some() + ); + assert!( + unsafe { Wrapper::::lookup(midi::MUSIC_DEVICE_START_NOTE_SELECT) } + .is_some() + ); + + assert_eq!( + unsafe { Wrapper::::close(wrapper) }, + au::noErr + ); + } + + #[test] + fn fixed_size_properties_reject_undersized_output_buffers() { + let wrapper = Wrapper::::new() as *mut c_void; + let properties = [ + ( + au::kAudioUnitProperty_ElementCount, + au::kAudioUnitScope_Input, + ), + (au::kAudioUnitProperty_Latency, au::kAudioUnitScope_Global), + (au::kAudioUnitProperty_TailTime, au::kAudioUnitScope_Global), + ( + au::kAudioUnitProperty_MaximumFramesPerSlice, + au::kAudioUnitScope_Global, + ), + ( + au::kAudioUnitProperty_BypassEffect, + au::kAudioUnitScope_Global, + ), + ( + au::kAudioUnitProperty_LastRenderError, + au::kAudioUnitScope_Global, + ), + ( + au::kAudioUnitProperty_InPlaceProcessing, + au::kAudioUnitScope_Global, + ), + ]; + let mut output = 0u64; + + for (property, scope) in properties { + let mut size = 0; + assert_eq!( + unsafe { + Wrapper::::get_property( + wrapper, + property, + scope, + 0, + &mut output as *mut u64 as *mut c_void, + &mut size, + ) + }, + au::kAudioUnitErr_InvalidPropertyValue, + "property {property} accepted an undersized output buffer" + ); + } + + assert_eq!( + unsafe { Wrapper::::close(wrapper) }, + au::noErr + ); + } #[test] fn classify_unit_decibels() { assert_eq!(classify_unit("dB"), au::kAudioUnitParameterUnit_Decibels); - assert_eq!(classify_unit("decibel"), au::kAudioUnitParameterUnit_Decibels); + assert_eq!( + classify_unit("decibel"), + au::kAudioUnitParameterUnit_Decibels + ); assert_eq!(classify_unit("dBFS"), au::kAudioUnitParameterUnit_Decibels); } @@ -2572,20 +3128,29 @@ mod tests { #[test] fn classify_unit_percent() { assert_eq!(classify_unit("%"), au::kAudioUnitParameterUnit_Percent); - assert_eq!(classify_unit("percent"), au::kAudioUnitParameterUnit_Percent); + assert_eq!( + classify_unit("percent"), + au::kAudioUnitParameterUnit_Percent + ); } #[test] fn classify_unit_seconds() { assert_eq!(classify_unit("ms"), au::kAudioUnitParameterUnit_Seconds); assert_eq!(classify_unit("sec"), au::kAudioUnitParameterUnit_Seconds); - assert_eq!(classify_unit("seconds"), au::kAudioUnitParameterUnit_Seconds); + assert_eq!( + classify_unit("seconds"), + au::kAudioUnitParameterUnit_Seconds + ); } #[test] fn classify_unit_generic_fallback() { assert_eq!(classify_unit(""), au::kAudioUnitParameterUnit_Generic); assert_eq!(classify_unit("ratio"), au::kAudioUnitParameterUnit_Generic); - assert_eq!(classify_unit("semitones"), au::kAudioUnitParameterUnit_Generic); + assert_eq!( + classify_unit("semitones"), + au::kAudioUnitParameterUnit_Generic + ); } } From 0371317c0c783844a803b019c8204c93621f925c Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 15 Aug 2026 14:51:37 +0900 Subject: [PATCH 2/5] ci: run CodeQL for stacked pull requests --- .github/workflows/codeql.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 94f9552a1..5adbb5e2a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,8 +5,6 @@ on: branches: - master pull_request: - branches: - - master schedule: - cron: '23 3 * * 1' workflow_dispatch: From 63f2913f3cee354f0dc1e68d13df63c8b88ce5d9 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 15 Aug 2026 14:59:13 +0900 Subject: [PATCH 3/5] fix(ci): gate AU exports to macOS --- plugins/examples/gain_gui_egui/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/examples/gain_gui_egui/src/lib.rs b/plugins/examples/gain_gui_egui/src/lib.rs index 095dab77c..159aff9e7 100644 --- a/plugins/examples/gain_gui_egui/src/lib.rs +++ b/plugins/examples/gain_gui_egui/src/lib.rs @@ -241,7 +241,7 @@ impl Vst3Plugin for Gain { &[Vst3SubCategory::Fx, Vst3SubCategory::Tools]; } -#[cfg(feature = "au")] +#[cfg(all(feature = "au", target_os = "macos"))] impl AuPlugin for Gain { const AU_TYPE: [u8; 4] = *b"aufx"; const AU_SUBTYPE: [u8; 4] = *b"GnEG"; @@ -250,5 +250,5 @@ impl AuPlugin for Gain { nih_export_clap!(Gain); nih_export_vst3!(Gain); -#[cfg(feature = "au")] +#[cfg(all(feature = "au", target_os = "macos"))] nih_export_au!(Gain); From 971d26c510841206dc9d99e590bc5f2725e05f6d Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 15 Aug 2026 15:10:22 +0900 Subject: [PATCH 4/5] fix(au): reclaim replaced MIDI callbacks --- src/wrapper/au/midi.rs | 108 +++++++++++++++++++++++++++++--------- src/wrapper/au/wrapper.rs | 8 +-- 2 files changed, 87 insertions(+), 29 deletions(-) diff --git a/src/wrapper/au/midi.rs b/src/wrapper/au/midi.rs index e791674bc..eac25b608 100644 --- a/src/wrapper/au/midi.rs +++ b/src/wrapper/au/midi.rs @@ -8,7 +8,7 @@ use std::borrow::Borrow; use std::collections::VecDeque; use std::ffi::c_void; use std::mem; -use std::sync::atomic::{AtomicPtr, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::Mutex; use au_sys as au; @@ -89,49 +89,105 @@ unsafe impl Sync for AuMidiOutputCallbackStruct {} /// Lock-free render-side storage for the host's MIDI output callback. /// -/// Property writes happen on a control thread and may allocate. Each installed -/// record remains owned by `records` until the AudioUnit is destroyed, so the -/// render thread can atomically load and copy a stable record without a mutex, -/// reference-count operation, or reclamation race. Hosts retain responsibility -/// for keeping the opaque `user_data` target alive while callbacks may be in -/// flight, as required by the AU callback contract. +/// Property writes happen on a control thread and may allocate or wait. The +/// render thread announces the brief pointer-copy section with a reader count, +/// and the serialized writer only reclaims the replaced immutable record once +/// that count reaches zero. The render path therefore never locks, allocates, +/// waits, or performs reference counting. Hosts retain responsibility for +/// keeping the opaque `user_data` target alive while callbacks may be in flight, +/// as required by the AU callback contract. +struct MidiOutputCallbackRecord { + callback: AuMidiOutputCallbackStruct, + #[cfg(test)] + live_records: std::sync::Arc, +} + +impl Drop for MidiOutputCallbackRecord { + fn drop(&mut self) { + #[cfg(test)] + self.live_records.fetch_sub(1, Ordering::SeqCst); + } +} + pub(super) struct MidiOutputCallbackSlot { - current: AtomicPtr, - records: Mutex>>, + current: AtomicPtr, + readers: AtomicUsize, + writer: Mutex<()>, + #[cfg(test)] + live_records: std::sync::Arc, } impl MidiOutputCallbackSlot { pub fn new() -> Self { Self { current: AtomicPtr::new(std::ptr::null_mut()), - records: Mutex::new(Vec::new()), + readers: AtomicUsize::new(0), + writer: Mutex::new(()), + #[cfg(test)] + live_records: std::sync::Arc::new(AtomicUsize::new(0)), } } pub fn store(&self, callback: Option) -> Result<(), ()> { - let Some(callback) = callback.filter(|callback| callback.callback.is_some()) else { - self.current.store(std::ptr::null_mut(), Ordering::Release); - return Ok(()); + let _writer = self.writer.lock().map_err(|_| ())?; + let replacement = match callback.filter(|callback| callback.callback.is_some()) { + Some(callback) => { + let record = Box::new(MidiOutputCallbackRecord { + callback, + #[cfg(test)] + live_records: self.live_records.clone(), + }); + #[cfg(test)] + self.live_records.fetch_add(1, Ordering::SeqCst); + Box::into_raw(record) + } + None => std::ptr::null_mut(), }; - let record = Box::new(callback); - let record_ptr = - record.as_ref() as *const AuMidiOutputCallbackStruct as *mut AuMidiOutputCallbackStruct; - let mut records = self.records.lock().map_err(|_| ())?; - records.push(record); - self.current.store(record_ptr, Ordering::Release); + let retired = self.current.swap(replacement, Ordering::SeqCst); + while self.readers.load(Ordering::SeqCst) != 0 { + std::thread::yield_now(); + } + + if !retired.is_null() { + // SAFETY: writers are serialized, and the reader count reached + // zero after this record stopped being current. A later reader can + // therefore only observe `replacement`. + unsafe { drop(Box::from_raw(retired)) }; + } + Ok(()) } #[inline] pub fn load(&self) -> Option { - let record = self.current.load(Ordering::Acquire); - if record.is_null() { + self.readers.fetch_add(1, Ordering::SeqCst); + let record = self.current.load(Ordering::SeqCst); + let callback = if record.is_null() { None } else { - // SAFETY: records are never mutated or reclaimed until this slot is - // dropped, and AU teardown is serialized against render. - Some(unsafe { *record }) + // SAFETY: the reader count prevents the writer from reclaiming the + // immutable record until after this copy has completed. + Some(unsafe { (*record).callback }) + }; + self.readers.fetch_sub(1, Ordering::SeqCst); + callback + } + + #[cfg(test)] + fn live_record_count(&self) -> usize { + self.live_records.load(Ordering::SeqCst) + } +} + +impl Drop for MidiOutputCallbackSlot { + fn drop(&mut self) { + debug_assert_eq!(*self.readers.get_mut(), 0); + let record = *self.current.get_mut(); + if !record.is_null() { + // SAFETY: `&mut self` proves that no safe reader or writer can still + // access this slot. AU teardown is also serialized against render. + unsafe { drop(Box::from_raw(record)) }; } } } @@ -918,7 +974,7 @@ mod tests { } #[test] - fn midi_output_callback_replacement_never_blocks_or_tears_render_loads() { + fn midi_output_callback_replacement_reclaims_records_without_blocking_render_loads() { use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::thread; @@ -955,7 +1011,9 @@ mod tests { writer.join().unwrap(); assert_eq!(slot.load().unwrap().user_data as usize, UPDATE_COUNT); + assert_eq!(slot.live_record_count(), 1); slot.store(None).unwrap(); assert!(slot.load().is_none()); + assert_eq!(slot.live_record_count(), 0); } } diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 924210b73..6a45b1b30 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -31,8 +31,8 @@ //! //! 3. **main↔audio shared state** — existing audio-input wiring is protected //! by a mutex and snapshotted into a local `Copy`. MIDI output callbacks -//! use an atomic pointer to immutable, lifetime-stable records so their -//! render path never blocks. +//! use an atomic pointer plus reader-count reclamation so their render path +//! never blocks while replaced callback records are reclaimed promptly. use std::any::Any; use std::cell::UnsafeCell; @@ -252,8 +252,8 @@ pub struct Wrapper { midi_input: midi::MidiInputState

, /// Host callback installed through `kAudioUnitProperty_MIDIOutputCallback`. - /// Loads from render are lock-free; retired records remain stable until - /// this AudioUnit is destroyed. + /// Loads from render are lock-free; the control-thread writer waits for the + /// brief pointer-copy section before reclaiming a retired record. midi_output_callback: midi::MidiOutputCallbackSlot, /// Per-aux-input-port render callbacks (elements 1, 2, … of Input scope). From 3cd1306fa238dec9f77329ea696ab42c9bb58ab9 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 15 Aug 2026 21:59:15 +0900 Subject: [PATCH 5/5] ci: add Logic AU input regression smoke --- .github/workflows/build.yml | 17 +- .github/workflows/docs.yml | 18 +- .github/workflows/test.yml | 12 +- scripts/au_logic_input_smoke.sh | 89 ++++++ scripts/au_logic_input_smoke/Info.plist | 18 ++ .../au_logic_input_smoke/LogicPullHost.swift | 266 ++++++++++++++++++ src/wrapper/au/wrapper.rs | 8 +- 7 files changed, 399 insertions(+), 29 deletions(-) create mode 100755 scripts/au_logic_input_smoke.sh create mode 100644 scripts/au_logic_input_smoke/Info.plist create mode 100644 scripts/au_logic_input_smoke/LogicPullHost.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad9b8aabb..35b056afe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,19 +1,14 @@ -name: Automated Builds +name: Integration Builds on: push: - branches: - - '**' - tags: - - '*' - pull_request: branches: - master workflow_dispatch: -# Packaging a commit that has already been superseded wastes a full macOS -# universal build. Tags get their own group, so release builds are never -# cancelled by a subsequent branch push. +# The real PR contract is covered by Tests (including a macOS AU host smoke). +# This full example-package matrix is an integration check for master or an +# operator-requested diagnostic, not a per-feature-branch artifact factory. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -107,17 +102,21 @@ jobs: fi - name: Determine build archive name + if: github.event_name == 'workflow_dispatch' run: | # Windows (usually) doesn't like colons in file names echo "ARCHIVE_NAME=nih-plugs-$(date -u +"%Y-%m-%d-%H%m%S")-${{ matrix.name }}" >> "$GITHUB_ENV" - name: Move all packaged plugin into a directory + if: github.event_name == 'workflow_dispatch' run: | # GitHub Action strips the top level directory, great, have another one mkdir -p "$ARCHIVE_NAME/$ARCHIVE_NAME" mv target/bundled/* "$ARCHIVE_NAME/$ARCHIVE_NAME" - name: Add an OS-specific readme file with installation instructions + if: github.event_name == 'workflow_dispatch' run: cp ".github/workflows/readme-${{ runner.os }}.txt" "$ARCHIVE_NAME/$ARCHIVE_NAME/README.txt" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: github.event_name == 'workflow_dispatch' with: name: ${{ env.ARCHIVE_NAME }} path: ${{ env.ARCHIVE_NAME }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 31c20f9f0..3e566c340 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,9 +1,11 @@ -name: Docs +name: Documentation on: push: branches: - master + pull_request: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -74,17 +76,3 @@ jobs: EOF - - name: Deploy to GitHub Pages - # The deploy target and its SSH key belong to upstream. On a fork this - # step can only fail — the secret is not there — so building the docs - # stays useful as a check while publishing is skipped. - if: github.repository == 'robbert-vdh/nih-plug' - uses: JamesIves/github-pages-deploy-action@360c8e75d0ee81732d0a5675c71e51b569df2ee8 # v4.3.0 - with: - branch: gh-pages - folder: target/doc - - # Having the gh-pages branch on this repository adds a whole bunch of - # noise to the GitHub feed if you follow this repo - repository-name: robbert-vdh/nih-plug-docs - ssh-key: ${{ secrets.DOCS_DEPLOY_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b04e0e9c..ae66154d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,10 @@ name: Tests on: push: - pull_request: branches: - master + pull_request: + workflow_dispatch: # A push that supersedes an in-flight run makes that run's result worthless, and # the macOS jobs here are slow enough that queued-but-stale runs delay the ones @@ -98,6 +99,15 @@ jobs: if: startsWith(matrix.os, 'macos') run: scripts/au_midi_smoke.sh + # Logic's input callback returns silence if the wrapper drops the render + # timestamp, and it may replace AudioBufferList mData pointers instead of + # writing into the caller's scratch buffers. Both previously yielded + # noErr plus silent output, so a normal AU instantiate or MIDI smoke test + # cannot catch this regression. + - name: Run the Logic-style Audio Unit input regression test + if: startsWith(matrix.os, 'macos') + run: scripts/au_logic_input_smoke.sh + # `nih_debug_assert!` becomes a panicking `debug_assert!` under `cfg(test)`, # so the release behaviour of contract-violating paths is only reachable # from `#[cfg(not(debug_assertions))]` tests. A different set of tests runs diff --git a/scripts/au_logic_input_smoke.sh b/scripts/au_logic_input_smoke.sh new file mode 100755 index 000000000..5d267d83e --- /dev/null +++ b/scripts/au_logic_input_smoke.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Logic Pro의 AUv2 effect input pull 계약을 재현해 AUD-831 회귀를 막는다. +# +# LogicPullHost는 유효한 sample time을 요구하면서 input callback 뒤 mData 포인터를 +# zero-copy source buffer로 바꾼다. 이 두 조건에서 Gain AU의 출력이 non-silent여야 +# 한다. AU 설치는 기존 컴포넌트를 임시로 보관하고 항상 복원한다. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/target/au_logic_input_smoke" +HOST_SOURCE="$ROOT_DIR/scripts/au_logic_input_smoke/LogicPullHost.swift" +HOST_PLIST="$ROOT_DIR/scripts/au_logic_input_smoke/Info.plist" +HOST_BINARY="$BUILD_DIR/LogicPullHost" +HOST_APP="$BUILD_DIR/LogicPullHost.app" +AU_SOURCE="$ROOT_DIR/target/bundled/Gain.component" +AU_DESTINATION="$HOME/Library/Audio/Plug-Ins/Components/Gain.component" +PREVIOUS_AU="$BUILD_DIR/previous/Gain.component" +INSTALLED_AU="$BUILD_DIR/installed/Gain.component" +RESULT_JSON="" + +mkdir -p "$BUILD_DIR" "$HOME/Library/Audio/Plug-Ins/Components" + +if [ -e "$PREVIOUS_AU" ]; then + echo "ERROR: 이전 실행의 AU 백업이 남아 있습니다: $PREVIOUS_AU" >&2 + exit 1 +fi + +restore_component() { + if [ -e "$AU_DESTINATION" ]; then + mkdir -p "$(dirname "$INSTALLED_AU")" + if [ -e "$INSTALLED_AU" ]; then + INSTALLED_AU="$BUILD_DIR/installed/Gain-$(date +%s).component" + fi + mv "$AU_DESTINATION" "$INSTALLED_AU" + fi + if [ -e "$PREVIOUS_AU" ]; then + mv "$PREVIOUS_AU" "$AU_DESTINATION" + fi + killall -9 AudioComponentRegistrar 2>/dev/null || true +} +trap restore_component EXIT + +if [ -e "$AU_DESTINATION" ]; then + mkdir -p "$(dirname "$PREVIOUS_AU")" + mv "$AU_DESTINATION" "$PREVIOUS_AU" +fi + +cd "$ROOT_DIR" +cargo xtask bundle gain --release +test -d "$AU_SOURCE" +ditto "$AU_SOURCE" "$AU_DESTINATION" +codesign --verify --deep --strict "$AU_DESTINATION" + +swiftc -O "$HOST_SOURCE" -o "$HOST_BINARY" +mkdir -p "$HOST_APP/Contents/MacOS" +cp "$HOST_BINARY" "$HOST_APP/Contents/MacOS/LogicPullHost" +cp "$HOST_PLIST" "$HOST_APP/Contents/Info.plist" +codesign --force --sign - "$HOST_APP" + +killall -9 AudioComponentRegistrar 2>/dev/null || true +for attempt in 1 2 3; do + candidate="$BUILD_DIR/result-$$-$attempt.json" + if open -W -n "$HOST_APP" --args \ + --type aufx --subtype MPgN --manufacturer MoiP \ + --sample-rate 44100 --block-size 512 --blocks 32 --out "$candidate"; then + if [ -f "$candidate" ]; then + RESULT_JSON="$candidate" + break + fi + fi + echo "Logic-style AU host attempt $attempt did not produce a result; retrying" >&2 + sleep 1 +done + +if [ -z "$RESULT_JSON" ]; then + echo "ERROR: Logic-style AU host produced no result" >&2 + exit 1 +fi + +cat "$RESULT_JSON" +if ! grep -q '"error":""' "$RESULT_JSON" || + ! grep -q '"target_found":true' "$RESULT_JSON" || + ! grep -q '"callback_status":0' "$RESULT_JSON" || + ! grep -q '"initialize_status":0' "$RESULT_JSON" || + ! grep -q '"render_status":0' "$RESULT_JSON" || + ! grep -q '"silent":false' "$RESULT_JSON"; then + exit 1 +fi diff --git a/scripts/au_logic_input_smoke/Info.plist b/scripts/au_logic_input_smoke/Info.plist new file mode 100644 index 000000000..7dd69c365 --- /dev/null +++ b/scripts/au_logic_input_smoke/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + LogicPullHost + CFBundleIdentifier + com.intrect.nih-plug.logic-pull-host + CFBundleName + LogicPullHost + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + + diff --git a/scripts/au_logic_input_smoke/LogicPullHost.swift b/scripts/au_logic_input_smoke/LogicPullHost.swift new file mode 100644 index 000000000..ffca843f3 --- /dev/null +++ b/scripts/au_logic_input_smoke/LogicPullHost.swift @@ -0,0 +1,266 @@ +// LogicPullHost는 Logic Pro가 실제로 요구한 AUv2 input-pull 계약을 재현한다. +// +// AUD-831의 무음은 단순한 MakeConnection 문제가 아니었다. Logic 스타일 +// SetRenderCallback은 유효하지 않은 sample timestamp에 무음을 반환하고, +// caller buffer를 채우는 대신 mData 포인터를 자신의 zero-copy buffer로 교체한다. +// 따라서 wrapper가 timestamp를 0으로 넘기거나 callback 뒤의 BufferList를 다시 +// 읽지 않으면 성공 상태(noErr)로도 효과 AU의 출력은 전부 무음이 된다. +// +// 이 host는 두 동작을 함께 재현하고 non-silent output을 요구한다. 실제 Logic +// 앱을 자동화하지는 않지만, 당시의 두 host contract 차이를 결정적으로 pin한다. + +import AudioToolbox +import Foundation + +struct Args { + var type = "aufx" + var subtype = "MPgN" + var manufacturer = "MoiP" + var sampleRate = 44_100.0 + var blockSize: UInt32 = 512 + var blocks = 32 + var frequency = 440.0 + var outputPath = "/tmp/nih_plug_logic_pull_result.json" +} + +var args = Args() +var arguments = CommandLine.arguments.dropFirst().makeIterator() +while let flag = arguments.next() { + let value = arguments.next() ?? "" + switch flag { + case "--type": args.type = value + case "--subtype": args.subtype = value + case "--manufacturer": args.manufacturer = value + case "--sample-rate": args.sampleRate = Double(value) ?? args.sampleRate + case "--block-size": args.blockSize = UInt32(value) ?? args.blockSize + case "--blocks": args.blocks = Int(value) ?? args.blocks + case "--frequency": args.frequency = Double(value) ?? args.frequency + case "--out": args.outputPath = value + default: break + } +} + +func fourCC(_ string: String) -> OSType { + var value: OSType = 0 + for byte in string.utf8.prefix(4) { + value = (value << 8) | OSType(byte) + } + return value +} + +func rms(_ samples: UnsafePointer, count: Int) -> Double { + var sum = 0.0 + for index in 0.. + let right: UnsafeMutablePointer + + init(frequency: Double, sampleRate: Double, capacity: Int) { + phaseIncrement = 2.0 * Double.pi * frequency / sampleRate + self.capacity = capacity + left = .allocate(capacity: capacity) + right = .allocate(capacity: capacity) + left.initialize(repeating: 0, count: capacity) + right.initialize(repeating: 0, count: capacity) + } + + deinit { + left.deallocate() + right.deallocate() + } +} + +let logicStylePull: AURenderCallback = { reference, _, timestamp, _, frameCount, ioData in + guard let ioData else { return noErr } + let context = Unmanaged.fromOpaque(reference).takeUnretainedValue() + let buffers = UnsafeMutableAudioBufferListPointer(ioData) + + // 결함 1 회귀: wrapper가 sample-time-valid 없이 pull하면 Logic처럼 noErr와 + // silence를 돌려준다. 따라서 양호한 output은 wrapper가 원 timestamp를 + // 그대로 전달했다는 증거다. + guard timestamp.pointee.mFlags.contains(.sampleTimeValid) else { + for buffer in buffers { + if let data = buffer.mData { + memset(data, 0, Int(buffer.mDataByteSize)) + } + } + return noErr + } + + let frames = min(Int(frameCount), context.capacity) + for index in 0.. 2.0 * Double.pi { + context.phase -= 2.0 * Double.pi + } + context.left[index] = sample + context.right[index] = sample + } + + // 결함 2 회귀: caller scratch를 채우지 않고 mData를 zero-copy source buffer로 + // 교체한다. wrapper가 callback 이전 scratch만 읽으면 downstream은 0을 본다. + if buffers.count > 0 { + buffers[0].mData = UnsafeMutableRawPointer(context.left) + buffers[0].mDataByteSize = UInt32(frames * MemoryLayout.size) + } + if buffers.count > 1 { + buffers[1].mData = UnsafeMutableRawPointer(context.right) + buffers[1].mDataByteSize = UInt32(frames * MemoryLayout.size) + } + return noErr +} + +var targetFound = false +var callbackStatus: OSStatus = -99_999 +var initializeStatus: OSStatus = -99_999 +var renderStatus: OSStatus = noErr +var firstNonSilentSample = -1 +var error = "" + +func run() { + var description = AudioComponentDescription( + componentType: fourCC(args.type), + componentSubType: fourCC(args.subtype), + componentManufacturer: fourCC(args.manufacturer), + componentFlags: 0, + componentFlagsMask: 0 + ) + guard let component = AudioComponentFindNext(nil, &description) else { + error = "target AudioComponent not found" + return + } + targetFound = true + + var target: AudioUnit? + guard AudioComponentInstanceNew(component, &target) == noErr, let unit = target else { + error = "AudioComponentInstanceNew failed" + return + } + + var format = AudioStreamBasicDescription( + mSampleRate: args.sampleRate, + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved, + mBytesPerPacket: 4, + mFramesPerPacket: 1, + mBytesPerFrame: 4, + mChannelsPerFrame: 2, + mBitsPerChannel: 32, + mReserved: 0 + ) + let formatSize = UInt32(MemoryLayout.size) + let context = LogicPullContext( + frequency: args.frequency, + sampleRate: args.sampleRate, + capacity: Int(args.blockSize) * 4 + ) + var callback = AURenderCallbackStruct( + inputProc: logicStylePull, + inputProcRefCon: Unmanaged.passUnretained(context).toOpaque() + ) + + let inputFormatStatus = AudioUnitSetProperty( + unit, + kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, + 0, + &format, + formatSize + ) + let outputFormatStatus = AudioUnitSetProperty( + unit, + kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, + 0, + &format, + formatSize + ) + guard inputFormatStatus == noErr, outputFormatStatus == noErr else { + error = "AudioUnitSetProperty(StreamFormat) failed" + return + } + + callbackStatus = AudioUnitSetProperty( + unit, + kAudioUnitProperty_SetRenderCallback, + kAudioUnitScope_Input, + 0, + &callback, + UInt32(MemoryLayout.size) + ) + guard callbackStatus == noErr else { + error = "AudioUnitSetProperty(SetRenderCallback) failed" + return + } + + initializeStatus = AudioUnitInitialize(unit) + guard initializeStatus == noErr else { + error = "AudioUnitInitialize failed" + return + } + defer { AudioUnitUninitialize(unit) } + + let frameCount = Int(args.blockSize) + let left = UnsafeMutablePointer.allocate(capacity: frameCount) + let right = UnsafeMutablePointer.allocate(capacity: frameCount) + defer { + left.deallocate() + right.deallocate() + } + let list = AudioBufferList.allocate(maximumBuffers: 2) + defer { free(list.unsafeMutablePointer) } + + var timestamp = AudioTimeStamp() + timestamp.mSampleTime = 0 + timestamp.mFlags = .sampleTimeValid + + for block in 0...size), + mData: UnsafeMutableRawPointer(left) + ) + list[1] = AudioBuffer( + mNumberChannels: 1, + mDataByteSize: args.blockSize * UInt32(MemoryLayout.size), + mData: UnsafeMutableRawPointer(right) + ) + + var flags = AudioUnitRenderActionFlags() + renderStatus = AudioUnitRender( + unit, + &flags, + ×tamp, + 0, + args.blockSize, + list.unsafeMutablePointer + ) + if renderStatus != noErr { + break + } + if rms(UnsafePointer(left), count: frameCount) > 0.0001, + firstNonSilentSample < 0 { + firstNonSilentSample = block * frameCount + } + timestamp.mSampleTime += Double(args.blockSize) + } +} + +run() + +let silent = firstNonSilentSample < 0 +let json = """ +{"target_found":\(targetFound),"callback_status":\(callbackStatus),"initialize_status":\(initializeStatus),"render_status":\(renderStatus),"first_nonsilent_sample":\(firstNonSilentSample),"rendered_samples":\(args.blocks * Int(args.blockSize)),"silent":\(silent),"error":"\(error)"} +""" +try? json.write(toFile: args.outputPath, atomically: true, encoding: .utf8) +print(json) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 6a45b1b30..0607d4ef8 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -74,10 +74,10 @@ use super::midi; /// /// AU v2 gives a host two ways to feed an effect's input bus: install a render /// callback (`kAudioUnitProperty_SetRenderCallback`), or wire a source unit -/// directly with this property. Logic Pro uses the latter; Ableton Live and -/// most other hosts use the former. Supporting only callbacks therefore looks -/// correct everywhere except Logic, where the input bus is never connected and -/// the plug-in renders silence while reporting no error at all. +/// directly with this property. These are separate host contracts and both +/// paths must work. The Logic Pro silence regression originated in the callback +/// pull path (timestamp forwarding and `mData` pointer replacement), not in +/// `MakeConnection`; this struct implements the independent connection path. /// /// `au_sys` 0.1.1 defines the property ID but not this struct, so the /// AudioToolbox layout is mirrored here.