From faba339ed9cfacb0dcd8aac213865c2db591e6f6 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 11:46:19 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat(au):=20Phase=201=20=E2=80=94=20minim?= =?UTF-8?q?um=20AU=20wrapper=20(AUv2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로운 `au` feature 추가. macOS-only. 추가: - src/plugin/au.rs — `AuPlugin` trait (4-char codes: TYPE/SUBTYPE/MANUFACTURER + VERSION) - src/wrapper/au.rs — `nih_export_au!` macro (factory function export) - src/wrapper/au/factory.rs — PluginInfo + fourcc helper - src/wrapper/au/wrapper.rs — Wrapper

struct: - AudioComponentPlugInInterface vtable (Open/Close/Lookup) - 핵심 selector: Initialize/Uninitialize/Reset/Render - GetPropertyInfo/GetProperty/SetProperty: SampleRate, StreamFormat, ElementCount, Latency, TailTime, MaximumFramesPerSlice, ParameterList, SupportedNumChannels, BypassEffect, LastRenderError, SetRenderCallback, InPlaceProcessing - Get/SetParameter (Phase 2 에서 wire) - Render: 현재 silence (Phase 3 에서 Plugin::process 연결) deps: - au-sys 0.1 (AUv2 C API FFI bindings) - objc2 0.6 / objc2-foundation / objc2-app-kit (Phase 4 NSView 용) Phase 1 검증: - cargo build --features au 컴파일 통과 - consuming crate 에서 nih_export_au!(MyPlugin) → _nih_plug_au_factory C symbol export 확인 --- Cargo.lock | 225 +++++++++++++++-- Cargo.toml | 9 + src/plugin.rs | 2 + src/plugin/au.rs | 36 +++ src/prelude.rs | 4 + src/wrapper.rs | 2 + src/wrapper/au.rs | 64 +++++ src/wrapper/au/factory.rs | 41 +++ src/wrapper/au/wrapper.rs | 506 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 864 insertions(+), 25 deletions(-) create mode 100644 src/plugin/au.rs create mode 100644 src/wrapper/au.rs create mode 100644 src/wrapper/au/factory.rs create mode 100644 src/wrapper/au/wrapper.rs diff --git a/Cargo.lock b/Cargo.lock index 81c28648b..cd0f1723e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -653,6 +653,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "au-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d6cc9577cd46e64cbed4dffba1df482b36e9c6b1dd113675f80ab763679d13" + [[package]] name = "autocfg" version = "1.4.0" @@ -835,6 +841,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.1" @@ -1692,6 +1707,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -3561,6 +3586,7 @@ dependencies = [ "assert_no_alloc", "atomic_float", "atomic_refcell", + "au-sys", "backtrace", "baseview 0.1.0 (git+https://github.com/RustAudio/baseview.git?rev=579130ecb4f9f315ae52190af42f0ea46aeaa4a2)", "bitflags 1.3.2", @@ -3578,6 +3604,9 @@ dependencies = [ "nih_log", "nih_plug_derive", "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parking_lot 0.12.3", "raw-window-handle 0.5.2", "rtrb", @@ -3888,6 +3917,15 @@ dependencies = [ "objc2-encode 4.1.0", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode 4.1.0", +] + [[package]] name = "objc2-app-kit" version = "0.2.2" @@ -3898,10 +3936,31 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", - "objc2-foundation", - "objc2-quartz-core", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -3914,7 +3973,18 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -3925,7 +3995,7 @@ checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3937,7 +4007,42 @@ dependencies = [ "bitflags 2.9.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -3948,10 +4053,20 @@ checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-location" version = "0.2.2" @@ -3961,7 +4076,32 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", - "objc2-foundation", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -3992,6 +4132,30 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -4000,8 +4164,8 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -4013,7 +4177,7 @@ dependencies = [ "bitflags 2.9.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -4025,10 +4189,21 @@ dependencies = [ "bitflags 2.9.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-symbols" version = "0.2.2" @@ -4036,7 +4211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -4048,13 +4223,13 @@ dependencies = [ "bitflags 2.9.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -4068,7 +4243,7 @@ checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -4081,7 +4256,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -5332,8 +5507,8 @@ dependencies = [ "js-sys", "log", "objc2 0.5.2", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", "raw-window-handle 0.6.2", "redox_syscall 0.5.9", "rustix 0.38.44", @@ -6920,8 +7095,8 @@ dependencies = [ "libc", "ndk 0.9.0", "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", "percent-encoding", diff --git a/Cargo.toml b/Cargo.toml index 6b70605d9..0b6ad6146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,9 @@ standalone = ["dep:baseview", "dep:clap", "dep:cpal", "dep:jack", "dep:midir", " # wrapper you might otherwise still include a couple (unused) symbols from the # `vst3-sys` crate. vst3 = ["dep:vst3-sys"] +# Enables the `nih_export_au!()` macro for Apple Audio Unit (AUv2) plugins on macOS. +# Builds AudioComponentPlugInInterface vtable + Cocoa view (objc2-app-kit). +au = ["dep:au-sys", "dep:objc2", "dep:objc2-foundation", "dep:objc2-app-kit"] # Add adapters to the Buffer object for reading the channel data to and from # `std::simd` vectors. Requires a nightly compiler. simd = [] @@ -127,6 +130,12 @@ libc = "0.2.124" objc = "0.2.7" core-foundation = "0.9.3" +# Used for the `au` feature (Apple Audio Unit v2 plugin wrapper). +au-sys = { version = "0.1", optional = true } +objc2 = { version = "0.6", optional = true } +objc2-foundation = { version = "0.3", optional = true } +objc2-app-kit = { version = "0.3", optional = true } + [target.'cfg(target_os = "windows")'.dependencies.windows] version = "0.44" features = [ diff --git a/src/plugin.rs b/src/plugin.rs index 390f23a79..d9822e3ee 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -11,6 +11,8 @@ use crate::prelude::{ pub mod clap; #[cfg(feature = "vst3")] pub mod vst3; +#[cfg(all(feature = "au", target_os = "macos"))] +pub mod au; /// A function that can execute a plugin's [`BackgroundTask`][Plugin::BackgroundTask]s. A plugin can /// dispatch these tasks from the `initialize()` function, the `process()` function, or the GUI, so diff --git a/src/plugin/au.rs b/src/plugin/au.rs new file mode 100644 index 000000000..315ea4bf7 --- /dev/null +++ b/src/plugin/au.rs @@ -0,0 +1,36 @@ +use super::Plugin; + +/// Provides auxiliary metadata needed for an Apple Audio Unit (AUv2) plugin. +/// +/// AU plugins are identified by three four-character codes: a type (e.g. `aufx` +/// for an effect, `aumu` for a music device), a subtype (a unique identifier +/// for the specific plugin from this manufacturer), and a manufacturer code +/// (a four-character identifier for the vendor — must be registered with Apple +/// for App Store distribution but for direct distribution any unique code works). +pub trait AuPlugin: Plugin { + /// The Audio Unit type four-character code. + /// + /// Common values: + /// - `*b"aufx"` for an effect (most plugins) + /// - `*b"aumu"` for a music device / instrument + /// - `*b"aumf"` for a music effect (effect that handles MIDI) + /// - `*b"augn"` for a generator + /// + /// See `au-sys` constants `kAudioUnitType_*` for the full list. + const AU_TYPE: [u8; 4]; + + /// A four-character subtype that uniquely identifies this plugin within + /// the manufacturer's product line. Choose any code that hasn't been used + /// for one of your other plugins. + const AU_SUBTYPE: [u8; 4]; + + /// A four-character manufacturer code. Apple recommends registering this + /// at for App Store distribution. + /// For direct distribution any unique code works. + const AU_MANUFACTURER: [u8; 4]; + + /// Optional version number encoded as a single 32-bit integer in + /// "major.minor.patch" form: `(major << 16) | (minor << 8) | patch`. + /// Defaults to 1.0.0 (`0x10000`). + const AU_VERSION: u32 = 0x0001_0000; +} diff --git a/src/prelude.rs b/src/prelude.rs index c37599d2e..30265021a 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -5,6 +5,8 @@ pub use std::num::NonZeroU32; pub use crate::debug::*; pub use crate::nih_export_clap; +#[cfg(all(feature = "au", target_os = "macos"))] +pub use crate::nih_export_au; #[cfg(feature = "vst3")] pub use crate::nih_export_vst3; #[cfg(feature = "standalone")] @@ -37,6 +39,8 @@ pub use crate::params::{BoolParam, FloatParam, IntParam, Param, ParamFlags}; pub use crate::plugin::clap::{ClapPlugin, PolyModulationConfig}; #[cfg(feature = "vst3")] pub use crate::plugin::vst3::Vst3Plugin; +#[cfg(all(feature = "au", target_os = "macos"))] +pub use crate::plugin::au::AuPlugin; pub use crate::plugin::{Plugin, ProcessStatus, TaskExecutor}; pub use crate::wrapper::clap::features::ClapFeature; pub use crate::wrapper::state::PluginState; diff --git a/src/wrapper.rs b/src/wrapper.rs index d2e745ed5..13b4be547 100644 --- a/src/wrapper.rs +++ b/src/wrapper.rs @@ -9,6 +9,8 @@ pub(crate) mod util; pub mod standalone; #[cfg(feature = "vst3")] pub mod vst3; +#[cfg(all(feature = "au", target_os = "macos"))] +pub mod au; // This is used by the wrappers. pub use util::setup_logger; diff --git a/src/wrapper/au.rs b/src/wrapper/au.rs new file mode 100644 index 000000000..6a1db3634 --- /dev/null +++ b/src/wrapper/au.rs @@ -0,0 +1,64 @@ +//! Apple Audio Unit (AUv2) wrapper for nih-plug. +//! +//! Phase 1 (current): minimum viable AU — registers via +//! `AudioComponentPlugInInterface`, exposes the basic property selectors +//! (`SampleRate`, `StreamFormat`, `Latency`, `MaximumFramesPerSlice`, +//! `SupportedNumChannels`), and renders silence. `auval` should validate +//! the component end-to-end at this stage. +//! +//! Phase 2: parameters + state. +//! Phase 3: real `Plugin::process` render path. +//! Phase 4: Cocoa view via `objc2-app-kit`. +//! Phase 5: bundle generation in `nih_plug_xtask`. + +mod factory; +mod wrapper; + +pub use factory::{fourcc, PluginInfo}; +pub use wrapper::{AuWrapper, Wrapper}; + +// Re-export `au-sys` so the macro can refer to its types without a +// separate dependency declaration in the consuming crate. +pub use au_sys; + +/// Export an Audio Unit plugin from this library using the provided plugin type. +/// +/// Generates an `extern "C"` factory function whose name (`nih_plug_au_factory`) +/// must match the `factoryFunction` value in the bundle's `Info.plist` +/// `AudioComponents` entry. The bundler in `nih_plug_xtask` writes that +/// automatically when `bundle-au` is used. +/// +/// Currently only one plugin per library is supported (Phase 1 limitation). +/// +/// # Example +/// +/// ```ignore +/// use nih_plug::prelude::*; +/// +/// struct MyEffect { /* ... */ } +/// impl Plugin for MyEffect { /* ... */ } +/// impl AuPlugin for MyEffect { +/// const AU_TYPE: [u8; 4] = *b"aufx"; +/// const AU_SUBTYPE: [u8; 4] = *b"MyFx"; +/// const AU_MANUFACTURER: [u8; 4] = *b"MyCo"; +/// } +/// +/// nih_export_au!(MyEffect); +/// ``` +#[macro_export] +macro_rules! nih_export_au { + ($plugin_ty:ty) => { + // C-linkage factory function. Apple's component manager calls this + // with the requested `AudioComponentDescription`; we ignore it (the + // Info.plist already filtered the lookup) and return a fresh + // wrapper instance. + #[doc(hidden)] + #[no_mangle] + pub unsafe extern "C" fn nih_plug_au_factory( + _desc: *const $crate::wrapper::au::au_sys::AudioComponentDescription, + ) -> *mut ::std::ffi::c_void { + let iface = $crate::wrapper::au::Wrapper::<$plugin_ty>::new(); + iface as *mut ::std::ffi::c_void + } + }; +} diff --git a/src/wrapper/au/factory.rs b/src/wrapper/au/factory.rs new file mode 100644 index 000000000..277437ec8 --- /dev/null +++ b/src/wrapper/au/factory.rs @@ -0,0 +1,41 @@ +//! Static metadata describing an AU plugin. Mirrors the role of `PluginInfo` +//! in the VST3 wrapper — collects everything the AU host needs to identify +//! and instantiate the plugin so it can be type-erased and stored in an +//! array (allowing `nih_export_au!` to export multiple plugins eventually). + +/// Metadata for one exported AU plugin. +#[derive(Debug)] +pub struct PluginInfo { + /// Plugin name, used for the `Info.plist`'s `CFBundleName` and the + /// `AudioComponents` array entries. + pub name: &'static str, + /// Manufacturer / vendor display name. + pub vendor: &'static str, + /// Plugin version string ("0.1.3"). Used for `CFBundleShortVersionString`. + pub version: &'static str, + /// Optional URL exposed via the manufacturer info. + pub url: &'static str, + /// Optional contact email. + pub email: &'static str, + + /// Audio Unit type (e.g. `*b"aufx"`). Internally stored as `u32` in + /// big-endian byte order — the order Apple's CoreAudio APIs use. + pub au_type: u32, + /// Audio Unit subtype four-char code. + pub au_subtype: u32, + /// Audio Unit manufacturer four-char code. + pub au_manufacturer: u32, + /// 32-bit version number `(major << 16) | (minor << 8) | patch`. + pub au_version: u32, +} + +/// Build a `u32` four-char code from a 4-byte array. +/// +/// Apple's CoreAudio APIs interpret these as big-endian uint32 — for `*b"aufx"` +/// the resulting `u32` is `0x61756678`. +pub const fn fourcc(bytes: [u8; 4]) -> u32 { + ((bytes[0] as u32) << 24) + | ((bytes[1] as u32) << 16) + | ((bytes[2] as u32) << 8) + | (bytes[3] as u32) +} diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs new file mode 100644 index 000000000..62d97d485 --- /dev/null +++ b/src/wrapper/au/wrapper.rs @@ -0,0 +1,506 @@ +//! Phase 1 AU wrapper — minimum viable. +//! +//! Implements the `AudioComponentPlugInInterface` vtable so that +//! `auval -v ` can: +//! 1. Find the component (via the bundle's `Info.plist` + factory function). +//! 2. Open / close it (lifecycle). +//! 3. Query basic properties (sample rate, element counts, latency). +//! 4. Set the stream format (host configures channel count + rate). +//! +//! Render and parameters are not yet wired — `AudioUnitRender` returns +//! silence, `kAudioUnitProperty_ParameterList` returns 0 entries. Phase 2/3 +//! will fill those in. + +use std::ffi::c_void; +use std::marker::PhantomData; +use std::ptr; +use std::sync::Arc; + +use au_sys as au; + +use crate::plugin::au::AuPlugin; + +/// One AU plugin instance. Owned by Apple's component manager via the +/// `AudioComponentPlugInInterface` pointer returned from the factory. +/// +/// The first field MUST be the vtable, since Apple's component manager +/// dispatches through `instance->vtable->Lookup(selector)` — i.e. the host +/// receives a pointer to this struct interpreted as `AudioComponentPlugInInterface*`, +/// then chases the function pointers stored at offset 0. +#[repr(C)] +pub struct Wrapper { + /// Apple-required vtable. MUST be the first field. + vtable: au::AudioComponentPlugInInterface, + + /// Reference back to the host's `AudioUnit` opaque handle, set in `Open`. + instance: au::AudioUnit, + + /// Sample rate set via `kAudioUnitProperty_StreamFormat`. Defaults to 44100 + /// before the host explicitly sets it. + sample_rate: f64, + + /// Maximum frames per `AudioUnitRender` slice. Hosts use this to size + /// their internal buffers and we honour it as an upper bound. + max_frames_per_slice: u32, + + /// Channel count in/out. Currently we mirror the input layout to output; + /// AU effects with mismatched in/out channel counts aren't supported in + /// Phase 1. + n_channels: u32, + + /// Latency in seconds, reported via `kAudioUnitProperty_Latency`. + latency_seconds: f64, + + /// Phantom marker so the type system knows about `P` even though Phase 1 + /// doesn't construct an actual `Plugin` instance yet (Phase 3 will). + _plugin: PhantomData

, +} + +impl Wrapper

{ + /// Allocates and returns a new instance, embedded in a `Box` and leaked + /// for the host to manage. The host calls `Close` later and we re-`Box` + /// to drop it. + /// + /// Returned pointer is `*mut AudioComponentPlugInInterface` because the + /// vtable is the first field. + pub fn new() -> *mut au::AudioComponentPlugInInterface { + let boxed = Box::new(Wrapper::

{ + vtable: au::AudioComponentPlugInInterface { + Open: Self::open, + Close: Self::close, + Lookup: Self::lookup, + reserved: ptr::null_mut(), + }, + instance: ptr::null_mut(), + sample_rate: 44_100.0, + max_frames_per_slice: 1024, + n_channels: 2, + latency_seconds: 0.0, + _plugin: PhantomData, + }); + let ptr = Box::into_raw(boxed); + // Casting to vtable pointer — the host will only ever access the first field. + ptr as *mut au::AudioComponentPlugInInterface + } + + /// Reconstruct `&mut Self` from the opaque `*mut c_void` pointer Apple's + /// component manager passes through every dispatch call. + /// + /// SAFETY: Must only be called with a pointer originally returned by + /// `Wrapper::new()`. The host's contract guarantees this. + unsafe fn from_ptr<'a>(ptr: *mut c_void) -> &'a mut Self { + &mut *(ptr as *mut Self) + } + + // ─── Vtable: lifecycle ──────────────────────────────────────────────── + + unsafe extern "C" fn open(self_ptr: *mut c_void, instance: au::AudioUnit) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + this.instance = instance; + au::noErr + } + + unsafe extern "C" fn close(self_ptr: *mut c_void) -> au::OSStatus { + // Re-box so it's dropped. + unsafe { + let _ = Box::from_raw(self_ptr as *mut Self); + } + au::noErr + } + + /// Selector dispatch table. Apple's component manager calls this once per + /// distinct selector and caches the result, so it must return a function + /// pointer or null for unsupported selectors. + unsafe extern "C" fn lookup(selector: au::SInt16) -> Option { + // SAFETY: each branch returns a function with a different concrete + // signature, so we transmute through `AudioComponentMethod` (which is + // a variadic-style fn pointer) to satisfy the vtable's union-style + // C ABI. This pattern is what Apple's own AUBase template uses. + let method: au::AudioComponentMethod = match selector { + au::kAudioUnitInitializeSelect => unsafe { + std::mem::transmute::(Self::initialize) + }, + au::kAudioUnitUninitializeSelect => unsafe { + std::mem::transmute::(Self::uninitialize) + }, + au::kAudioUnitGetPropertyInfoSelect => unsafe { + std::mem::transmute::( + Self::get_property_info, + ) + }, + au::kAudioUnitGetPropertySelect => unsafe { + std::mem::transmute::(Self::get_property) + }, + au::kAudioUnitSetPropertySelect => unsafe { + std::mem::transmute::(Self::set_property) + }, + au::kAudioUnitGetParameterSelect => unsafe { + std::mem::transmute::(Self::get_parameter) + }, + au::kAudioUnitSetParameterSelect => unsafe { + std::mem::transmute::(Self::set_parameter) + }, + au::kAudioUnitResetSelect => unsafe { + std::mem::transmute::(Self::reset) + }, + au::kAudioUnitRenderSelect => unsafe { + std::mem::transmute::(Self::render) + }, + // Add property listeners: stubbed (we don't notify, but auval + // expects the selector to exist). + au::kAudioUnitAddPropertyListenerSelect => unsafe { + std::mem::transmute::( + Self::add_property_listener, + ) + }, + au::kAudioUnitRemovePropertyListenerWithUserDataSelect => unsafe { + std::mem::transmute::( + Self::remove_property_listener_with_user_data, + ) + }, + _ => return None, + }; + Some(method) + } + + // ─── Vtable: AU dispatch methods ────────────────────────────────────── + + unsafe extern "C" fn initialize(_self_ptr: *mut c_void) -> au::OSStatus { + au::noErr + } + + unsafe extern "C" fn uninitialize(_self_ptr: *mut c_void) -> au::OSStatus { + au::noErr + } + + unsafe extern "C" fn reset( + _self_ptr: *mut c_void, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + ) -> au::OSStatus { + au::noErr + } + + /// Returns metadata about a property: its size and whether it can be set. + /// `auval` calls this for almost every property to probe what the unit + /// supports. + unsafe extern "C" fn get_property_info( + self_ptr: *mut c_void, + id: au::AudioUnitPropertyID, + scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + out_data_size: *mut au::UInt32, + out_writable: *mut au::Boolean, + ) -> au::OSStatus { + let _this = unsafe { Self::from_ptr(self_ptr) }; + + // Helper to set the two output values when both pointers are present. + let respond = |size: au::UInt32, writable: bool| -> au::OSStatus { + unsafe { + if !out_data_size.is_null() { + *out_data_size = size; + } + if !out_writable.is_null() { + *out_writable = if writable { 1 } else { 0 }; + } + } + au::noErr + }; + + match id { + au::kAudioUnitProperty_SampleRate + 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_ElementCount => { + respond(std::mem::size_of::() as u32, false) + } + au::kAudioUnitProperty_Latency if scope == au::kAudioUnitScope_Global => { + respond(std::mem::size_of::() as u32, false) + } + au::kAudioUnitProperty_TailTime if scope == au::kAudioUnitScope_Global => { + respond(std::mem::size_of::() as u32, false) + } + au::kAudioUnitProperty_MaximumFramesPerSlice + if scope == au::kAudioUnitScope_Global => + { + respond(std::mem::size_of::() as u32, true) + } + au::kAudioUnitProperty_ParameterList => { + // 0 parameters in Phase 1 — return 0 size. + respond(0, false) + } + au::kAudioUnitProperty_SupportedNumChannels + if scope == au::kAudioUnitScope_Global => + { + respond(std::mem::size_of::() as u32, false) + } + au::kAudioUnitProperty_BypassEffect if scope == au::kAudioUnitScope_Global => { + respond(std::mem::size_of::() as u32, true) + } + au::kAudioUnitProperty_LastRenderError if scope == au::kAudioUnitScope_Global => { + respond(std::mem::size_of::() as u32, false) + } + au::kAudioUnitProperty_SetRenderCallback if scope == au::kAudioUnitScope_Input => { + respond(std::mem::size_of::() as u32, true) + } + au::kAudioUnitProperty_InPlaceProcessing => { + respond(std::mem::size_of::() as u32, true) + } + _ => au::kAudioUnitErr_InvalidProperty, + } + } + + unsafe extern "C" fn get_property( + self_ptr: *mut c_void, + id: au::AudioUnitPropertyID, + scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + out_data: *mut c_void, + io_data_size: *mut au::UInt32, + ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + + if out_data.is_null() || io_data_size.is_null() { + return au::kAudioUnitErr_InvalidParameter; + } + + match id { + au::kAudioUnitProperty_SampleRate => { + if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + unsafe { + *(out_data as *mut au::Float64) = this.sample_rate; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_ElementCount => { + let count: au::UInt32 = match scope { + au::kAudioUnitScope_Input | au::kAudioUnitScope_Output => 1, + au::kAudioUnitScope_Global => 1, + _ => 0, + }; + unsafe { + *(out_data as *mut au::UInt32) = count; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_Latency if scope == au::kAudioUnitScope_Global => { + unsafe { + *(out_data as *mut au::Float64) = this.latency_seconds; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_TailTime if scope == au::kAudioUnitScope_Global => { + unsafe { + *(out_data as *mut au::Float64) = 0.0; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_MaximumFramesPerSlice => { + unsafe { + *(out_data as *mut au::UInt32) = this.max_frames_per_slice; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_StreamFormat => { + if (unsafe { *io_data_size } as usize) + < std::mem::size_of::() + { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let asbd = au::AudioStreamBasicDescription { + mSampleRate: this.sample_rate, + mFormatID: au::kAudioFormatLinearPCM, + mFormatFlags: au::kAudioFormatFlagIsFloat + | au::kAudioFormatFlagIsPacked + | au::kAudioFormatFlagIsNonInterleaved, + mBytesPerPacket: 4, + mFramesPerPacket: 1, + mBytesPerFrame: 4, + mChannelsPerFrame: this.n_channels, + mBitsPerChannel: 32, + mReserved: 0, + }; + unsafe { + *(out_data as *mut au::AudioStreamBasicDescription) = asbd; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_SupportedNumChannels + if scope == au::kAudioUnitScope_Global => + { + if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + // -1 / -1 means "any matching in/out channel count" — i.e. mono and stereo both work. + unsafe { + *(out_data as *mut au::AUChannelInfo) = au::AUChannelInfo { + inChannels: -1, + outChannels: -1, + }; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_BypassEffect if scope == au::kAudioUnitScope_Global => { + unsafe { + *(out_data as *mut au::UInt32) = 0; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_LastRenderError if scope == au::kAudioUnitScope_Global => { + unsafe { + *(out_data as *mut au::OSStatus) = au::noErr; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + au::kAudioUnitProperty_InPlaceProcessing => { + unsafe { + *(out_data as *mut au::UInt32) = 1; + *io_data_size = std::mem::size_of::() as u32; + } + au::noErr + } + _ => au::kAudioUnitErr_InvalidProperty, + } + } + + unsafe extern "C" fn set_property( + self_ptr: *mut c_void, + id: au::AudioUnitPropertyID, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + in_data: *const c_void, + in_data_size: au::UInt32, + ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + + match id { + au::kAudioUnitProperty_SampleRate => { + if (in_data_size as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + this.sample_rate = unsafe { *(in_data as *const au::Float64) }; + au::noErr + } + au::kAudioUnitProperty_StreamFormat => { + if (in_data_size as usize) + < std::mem::size_of::() + { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let asbd = unsafe { &*(in_data as *const au::AudioStreamBasicDescription) }; + this.sample_rate = asbd.mSampleRate; + this.n_channels = asbd.mChannelsPerFrame; + au::noErr + } + au::kAudioUnitProperty_MaximumFramesPerSlice => { + if (in_data_size as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + this.max_frames_per_slice = unsafe { *(in_data as *const au::UInt32) }; + au::noErr + } + au::kAudioUnitProperty_BypassEffect => au::noErr, + au::kAudioUnitProperty_SetRenderCallback => au::noErr, + au::kAudioUnitProperty_InPlaceProcessing => au::noErr, + _ => au::kAudioUnitErr_InvalidProperty, + } + } + + unsafe extern "C" fn get_parameter( + _self_ptr: *mut c_void, + _id: au::AudioUnitParameterID, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + _out_value: *mut au::AudioUnitParameterValue, + ) -> au::OSStatus { + // Phase 1: no parameters. + au::kAudioUnitErr_InvalidParameter + } + + unsafe extern "C" fn set_parameter( + _self_ptr: *mut c_void, + _id: au::AudioUnitParameterID, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + _value: au::AudioUnitParameterValue, + _buffer_offset_in_frames: au::UInt32, + ) -> au::OSStatus { + au::kAudioUnitErr_InvalidParameter + } + + /// Phase 1 render: silence. Phase 3 will hook in the actual `Plugin::process`. + unsafe extern "C" fn render( + _self_ptr: *mut c_void, + _io_action_flags: *mut au::AudioUnitRenderActionFlags, + _in_time_stamp: *const au::AudioTimeStamp, + _in_output_bus_number: au::UInt32, + in_number_frames: au::UInt32, + io_data: *mut au::AudioBufferList, + ) -> au::OSStatus { + if io_data.is_null() { + return au::kAudioUnitErr_InvalidParameter; + } + unsafe { + let bl = &*io_data; + let n_buffers = bl.mNumberBuffers as usize; + let buffers = bl.mBuffers.as_ptr(); + for i in 0..n_buffers { + let buf = &*buffers.add(i); + if buf.mData.is_null() { + continue; + } + let n_samples = in_number_frames as usize * buf.mNumberChannels as usize; + ptr::write_bytes(buf.mData as *mut f32, 0, n_samples); + } + } + au::noErr + } + + unsafe extern "C" fn add_property_listener( + _self_ptr: *mut c_void, + _id: au::AudioUnitPropertyID, + _proc: au::AudioUnitPropertyListenerProc, + _user_data: *mut c_void, + ) -> au::OSStatus { + au::noErr + } + + unsafe extern "C" fn remove_property_listener_with_user_data( + _self_ptr: *mut c_void, + _id: au::AudioUnitPropertyID, + _proc: au::AudioUnitPropertyListenerProc, + _user_data: *mut c_void, + ) -> au::OSStatus { + au::noErr + } +} + +/// Marker so `Wrapper

` is `Send` — Apple's host calls vtable methods from +/// arbitrary threads but only one thread at a time per instance. +unsafe impl Send for Wrapper

{} + +/// Public re-exports for the `nih_export_au!` macro. +pub use Wrapper as AuWrapper; + +/// Hold a strong reference to the plugin metadata so the macro can build a +/// `static PLUGIN_INFO` and thread it through the factory function. +#[allow(dead_code)] +pub struct PluginRef(Arc<()>, PhantomData

); From ae63dad1a9045b7da1fdc69f7013cede52c34cae Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 12:02:18 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat(au):=20Phase=202=20=E2=80=94=20param?= =?UTF-8?q?eter=20automation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapper

가 plugin instance + params 보유: - P::default() 로 plugin 생성, Plugin::params() 의 Arc 를 strong reference 로 유지 (ParamPtr 의 raw pointer 안전성 보장) - params_by_id: param_map() 결과를 declaration order 로 캐시 — AU parameter ID = 인덱스 Property selectors: - kAudioUnitProperty_ParameterList: AudioUnitParameterID 배열 반환 - kAudioUnitProperty_ParameterInfo: name(52-byte legacy buffer), min/max/default(plain), unit, flags(IsReadable/Writable/CanRamp) Parameter dispatch: - AudioUnitGetParameter: unmodulated_normalized_value() → preview_plain() → plain value 반환 - AudioUnitSetParameter: preview_normalized() → set_normalized_value() Unit 분류 휴리스틱: nih-plug Param::unit() 문자열에서 dB/Hz/%/sec 키워드 감지하여 AU unit 상수 (Decibels/Hertz/Percent/Seconds) 매핑. step_count = Some(1) → Boolean, Some(>=2) → Indexed (enum). Phase 3 (render + Plugin::process 통합) 다음. --- src/wrapper/au/wrapper.rs | 291 +++++++++++++++++++++++++++----------- 1 file changed, 206 insertions(+), 85 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 62d97d485..d22615742 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1,69 +1,83 @@ -//! Phase 1 AU wrapper — minimum viable. +//! AU wrapper, Phase 2. //! -//! Implements the `AudioComponentPlugInInterface` vtable so that -//! `auval -v ` can: -//! 1. Find the component (via the bundle's `Info.plist` + factory function). -//! 2. Open / close it (lifecycle). -//! 3. Query basic properties (sample rate, element counts, latency). -//! 4. Set the stream format (host configures channel count + rate). +//! Hosts the plugin's `Params` so: +//! - `kAudioUnitProperty_ParameterList` returns the AU parameter ID array +//! - `kAudioUnitProperty_ParameterInfo` returns name / range / default / unit +//! - `AudioUnitGet/SetParameter` hand off to the underlying nih-plug `ParamPtr` //! -//! Render and parameters are not yet wired — `AudioUnitRender` returns -//! silence, `kAudioUnitProperty_ParameterList` returns 0 entries. Phase 2/3 -//! will fill those in. +//! AU parameter IDs are simply the index into `param_map()` — stable across +//! one binary build (the nih-plug derive guarantees field declaration order). +//! +//! Render is still Phase 1 (silence). Phase 3 will wire the actual +//! `Plugin::process()` path. use std::ffi::c_void; -use std::marker::PhantomData; use std::ptr; use std::sync::Arc; use au_sys as au; +use crate::params::internals::ParamPtr; +use crate::params::Params; use crate::plugin::au::AuPlugin; +use crate::plugin::Plugin; /// One AU plugin instance. Owned by Apple's component manager via the /// `AudioComponentPlugInInterface` pointer returned from the factory. /// /// The first field MUST be the vtable, since Apple's component manager -/// dispatches through `instance->vtable->Lookup(selector)` — i.e. the host -/// receives a pointer to this struct interpreted as `AudioComponentPlugInInterface*`, -/// then chases the function pointers stored at offset 0. +/// dispatches through `instance->vtable->Lookup(selector)`. #[repr(C)] pub struct Wrapper { /// Apple-required vtable. MUST be the first field. vtable: au::AudioComponentPlugInInterface, - /// Reference back to the host's `AudioUnit` opaque handle, set in `Open`. + /// Host's `AudioUnit` opaque handle, set in `Open`. instance: au::AudioUnit, - /// Sample rate set via `kAudioUnitProperty_StreamFormat`. Defaults to 44100 - /// before the host explicitly sets it. + /// Sample rate set via `kAudioUnitProperty_StreamFormat`. sample_rate: f64, - /// Maximum frames per `AudioUnitRender` slice. Hosts use this to size - /// their internal buffers and we honour it as an upper bound. + /// Maximum frames per `AudioUnitRender` slice. max_frames_per_slice: u32, - /// Channel count in/out. Currently we mirror the input layout to output; - /// AU effects with mismatched in/out channel counts aren't supported in - /// Phase 1. + /// Channel count set via `StreamFormat`. n_channels: u32, - /// Latency in seconds, reported via `kAudioUnitProperty_Latency`. + /// Latency reported via `kAudioUnitProperty_Latency`. latency_seconds: f64, - /// Phantom marker so the type system knows about `P` even though Phase 1 - /// doesn't construct an actual `Plugin` instance yet (Phase 3 will). - _plugin: PhantomData

, + /// The plugin instance. Kept for the entire lifetime of the wrapper so + /// the `ParamPtr` raw pointers in `params_by_id` remain valid. + _plugin: Box

, + + /// Parameter handles in declaration order. The AU parameter ID is the + /// index into this vec. + params_by_id: Vec, + + /// Strong reference back to the `Params` object referenced by every + /// `ParamPtr` in `params_by_id`. + _params_arc: Arc, +} + +struct ParamEntry { + /// Stable string ID — currently unused but kept for future state save/load. + #[allow(dead_code)] + id_str: String, + ptr: ParamPtr, } impl Wrapper

{ - /// Allocates and returns a new instance, embedded in a `Box` and leaked - /// for the host to manage. The host calls `Close` later and we re-`Box` - /// to drop it. - /// - /// Returned pointer is `*mut AudioComponentPlugInInterface` because the - /// vtable is the first field. pub fn new() -> *mut au::AudioComponentPlugInInterface { + let plugin = Box::new(P::default()); + let params_arc = plugin.params(); + + let params_by_id: Vec = params_arc + .param_map() + .into_iter() + .map(|(id_str, ptr, _group)| ParamEntry { id_str, ptr }) + .collect(); + let boxed = Box::new(Wrapper::

{ vtable: au::AudioComponentPlugInInterface { Open: Self::open, @@ -76,24 +90,17 @@ impl Wrapper

{ max_frames_per_slice: 1024, n_channels: 2, latency_seconds: 0.0, - _plugin: PhantomData, + _plugin: plugin, + params_by_id, + _params_arc: params_arc, }); - let ptr = Box::into_raw(boxed); - // Casting to vtable pointer — the host will only ever access the first field. - ptr as *mut au::AudioComponentPlugInInterface + Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface } - /// Reconstruct `&mut Self` from the opaque `*mut c_void` pointer Apple's - /// component manager passes through every dispatch call. - /// - /// SAFETY: Must only be called with a pointer originally returned by - /// `Wrapper::new()`. The host's contract guarantees this. unsafe fn from_ptr<'a>(ptr: *mut c_void) -> &'a mut Self { &mut *(ptr as *mut Self) } - // ─── Vtable: lifecycle ──────────────────────────────────────────────── - unsafe extern "C" fn open(self_ptr: *mut c_void, instance: au::AudioUnit) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; this.instance = instance; @@ -101,21 +108,13 @@ impl Wrapper

{ } unsafe extern "C" fn close(self_ptr: *mut c_void) -> au::OSStatus { - // Re-box so it's dropped. unsafe { let _ = Box::from_raw(self_ptr as *mut Self); } au::noErr } - /// Selector dispatch table. Apple's component manager calls this once per - /// distinct selector and caches the result, so it must return a function - /// pointer or null for unsupported selectors. unsafe extern "C" fn lookup(selector: au::SInt16) -> Option { - // SAFETY: each branch returns a function with a different concrete - // signature, so we transmute through `AudioComponentMethod` (which is - // a variadic-style fn pointer) to satisfy the vtable's union-style - // C ABI. This pattern is what Apple's own AUBase template uses. let method: au::AudioComponentMethod = match selector { au::kAudioUnitInitializeSelect => unsafe { std::mem::transmute::(Self::initialize) @@ -146,8 +145,6 @@ impl Wrapper

{ au::kAudioUnitRenderSelect => unsafe { std::mem::transmute::(Self::render) }, - // Add property listeners: stubbed (we don't notify, but auval - // expects the selector to exist). au::kAudioUnitAddPropertyListenerSelect => unsafe { std::mem::transmute::( Self::add_property_listener, @@ -163,8 +160,6 @@ impl Wrapper

{ Some(method) } - // ─── Vtable: AU dispatch methods ────────────────────────────────────── - unsafe extern "C" fn initialize(_self_ptr: *mut c_void) -> au::OSStatus { au::noErr } @@ -181,9 +176,6 @@ impl Wrapper

{ au::noErr } - /// Returns metadata about a property: its size and whether it can be set. - /// `auval` calls this for almost every property to probe what the unit - /// supports. unsafe extern "C" fn get_property_info( self_ptr: *mut c_void, id: au::AudioUnitPropertyID, @@ -192,9 +184,8 @@ impl Wrapper

{ out_data_size: *mut au::UInt32, out_writable: *mut au::Boolean, ) -> au::OSStatus { - let _this = unsafe { Self::from_ptr(self_ptr) }; + let this = unsafe { Self::from_ptr(self_ptr) }; - // Helper to set the two output values when both pointers are present. let respond = |size: au::UInt32, writable: bool| -> au::OSStatus { unsafe { if !out_data_size.is_null() { @@ -232,9 +223,18 @@ impl Wrapper

{ { respond(std::mem::size_of::() as u32, true) } - au::kAudioUnitProperty_ParameterList => { - // 0 parameters in Phase 1 — return 0 size. - respond(0, false) + au::kAudioUnitProperty_ParameterList if scope == au::kAudioUnitScope_Global => { + let n_params = this.params_by_id.len() as u32; + respond( + n_params * std::mem::size_of::() as u32, + false, + ) + } + au::kAudioUnitProperty_ParameterInfo if scope == au::kAudioUnitScope_Global => { + respond( + std::mem::size_of::() as u32, + false, + ) } au::kAudioUnitProperty_SupportedNumChannels if scope == au::kAudioUnitScope_Global => @@ -248,7 +248,10 @@ impl Wrapper

{ respond(std::mem::size_of::() as u32, false) } au::kAudioUnitProperty_SetRenderCallback if scope == au::kAudioUnitScope_Input => { - respond(std::mem::size_of::() as u32, true) + respond( + std::mem::size_of::() as u32, + true, + ) } au::kAudioUnitProperty_InPlaceProcessing => { respond(std::mem::size_of::() as u32, true) @@ -261,7 +264,7 @@ impl Wrapper

{ self_ptr: *mut c_void, id: au::AudioUnitPropertyID, scope: au::AudioUnitScope, - _element: au::AudioUnitElement, + element: au::AudioUnitElement, out_data: *mut c_void, io_data_size: *mut au::UInt32, ) -> au::OSStatus { @@ -336,7 +339,43 @@ impl Wrapper

{ }; 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 + } + au::kAudioUnitProperty_ParameterList if scope == au::kAudioUnitScope_Global => { + let n = this.params_by_id.len(); + let needed = (n * std::mem::size_of::()) as u32; + if unsafe { *io_data_size } < needed { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let dst = out_data as *mut au::AudioUnitParameterID; + for i in 0..n { + unsafe { + *dst.add(i) = i as au::AudioUnitParameterID; + } + } + unsafe { + *io_data_size = needed; + } + au::noErr + } + au::kAudioUnitProperty_ParameterInfo if scope == au::kAudioUnitScope_Global => { + let idx = element as usize; + let entry = match this.params_by_id.get(idx) { + Some(e) => e, + None => return au::kAudioUnitErr_InvalidParameter, + }; + if (unsafe { *io_data_size } as usize) + < std::mem::size_of::() + { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let info = build_parameter_info(entry); + unsafe { + *(out_data as *mut au::AudioUnitParameterInfo) = info; + *io_data_size = std::mem::size_of::() as u32; } au::noErr } @@ -346,7 +385,6 @@ impl Wrapper

{ if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } - // -1 / -1 means "any matching in/out channel count" — i.e. mono and stereo both work. unsafe { *(out_data as *mut au::AUChannelInfo) = au::AUChannelInfo { inChannels: -1, @@ -424,29 +462,55 @@ impl Wrapper

{ } } + /// Read the current parameter value, projected from the plugin's + /// normalised `[0, 1]` representation back to the plain (display) value. unsafe extern "C" fn get_parameter( - _self_ptr: *mut c_void, - _id: au::AudioUnitParameterID, + self_ptr: *mut c_void, + id: au::AudioUnitParameterID, _scope: au::AudioUnitScope, _element: au::AudioUnitElement, - _out_value: *mut au::AudioUnitParameterValue, + out_value: *mut au::AudioUnitParameterValue, ) -> au::OSStatus { - // Phase 1: no parameters. - au::kAudioUnitErr_InvalidParameter + let this = unsafe { Self::from_ptr(self_ptr) }; + let entry = match this.params_by_id.get(id as usize) { + Some(e) => e, + None => return au::kAudioUnitErr_InvalidParameter, + }; + if out_value.is_null() { + return au::kAudioUnitErr_InvalidParameter; + } + let normalized = unsafe { entry.ptr.unmodulated_normalized_value() }; + let plain = unsafe { entry.ptr.preview_plain(normalized) }; + unsafe { + *out_value = plain; + } + au::noErr } + /// Set a parameter from the host. AU sends the plain value; convert back + /// to the [0, 1] normalised range nih-plug expects. unsafe extern "C" fn set_parameter( - _self_ptr: *mut c_void, - _id: au::AudioUnitParameterID, + self_ptr: *mut c_void, + id: au::AudioUnitParameterID, _scope: au::AudioUnitScope, _element: au::AudioUnitElement, - _value: au::AudioUnitParameterValue, + value: au::AudioUnitParameterValue, _buffer_offset_in_frames: au::UInt32, ) -> au::OSStatus { - au::kAudioUnitErr_InvalidParameter + let this = unsafe { Self::from_ptr(self_ptr) }; + let entry = match this.params_by_id.get(id as usize) { + Some(e) => e, + None => return au::kAudioUnitErr_InvalidParameter, + }; + let normalized = unsafe { entry.ptr.preview_normalized(value) }; + unsafe { + let _ = entry.ptr.set_normalized_value(normalized); + } + au::noErr } - /// Phase 1 render: silence. Phase 3 will hook in the actual `Plugin::process`. + /// Phase 2 render: still silence — Phase 3 will hook the real + /// `Plugin::process`. unsafe extern "C" fn render( _self_ptr: *mut c_void, _io_action_flags: *mut au::AudioUnitRenderActionFlags, @@ -493,14 +557,71 @@ impl Wrapper

{ } } -/// Marker so `Wrapper

` is `Send` — Apple's host calls vtable methods from -/// arbitrary threads but only one thread at a time per instance. +/// Build the `AudioUnitParameterInfo` blob the host queries for each +/// parameter. Populates the legacy 52-byte name buffer; the CFString slot is +/// left null until Phase 4 brings in the CoreFoundation bridge. +fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { + let mut info: au::AudioUnitParameterInfo = unsafe { std::mem::zeroed() }; + + // Legacy 52-byte name buffer. AU tools and older hosts read this when the + // modern CFString slot is absent. + let name_str = unsafe { entry.ptr.name() }; + let max = info.name.len() - 1; + let bytes = name_str.as_bytes(); + let n = bytes.len().min(max); + for i in 0..n { + info.name[i] = bytes[i] as std::os::raw::c_char; + } + + let normalized_default = unsafe { entry.ptr.default_normalized_value() }; + let default_plain = unsafe { entry.ptr.preview_plain(normalized_default) }; + let min_plain = unsafe { entry.ptr.preview_plain(0.0) }; + let max_plain = unsafe { entry.ptr.preview_plain(1.0) }; + + info.minValue = min_plain; + info.maxValue = max_plain; + info.defaultValue = default_plain; + + // Boolean parameters always have step_count == Some(1); enums Some(n>=2). + // Both map to AU's `Indexed`. Floats have None — we then guess from the + // unit string. + let unit = match unsafe { entry.ptr.step_count() } { + Some(1) => au::kAudioUnitParameterUnit_Boolean, + Some(_) => au::kAudioUnitParameterUnit_Indexed, + None => { + let unit_str = unsafe { entry.ptr.unit() }; + classify_unit(unit_str) + } + }; + info.unit = unit; + info.unitName = std::ptr::null_mut(); + info.cfNameString = std::ptr::null_mut(); + info.clumpID = 0; + + info.flags = au::kAudioUnitParameterFlag_IsReadable + | au::kAudioUnitParameterFlag_IsWritable + | au::kAudioUnitParameterFlag_CanRamp; + + info +} + +/// Heuristic mapping from nih-plug's `Param::unit()` display string to an +/// AU unit ID. Hosts use this to format the value in their generic UI. +fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { + let lower = unit.to_ascii_lowercase(); + if lower.contains("db") || lower.contains("decibel") { + au::kAudioUnitParameterUnit_Decibels + } else if lower.contains("hz") || lower.contains("hertz") || lower == "khz" { + au::kAudioUnitParameterUnit_Hertz + } else if lower.contains('%') || lower.contains("percent") { + au::kAudioUnitParameterUnit_Percent + } else if lower.contains("ms") || lower.contains("sec") || lower.contains("second") { + au::kAudioUnitParameterUnit_Seconds + } else { + au::kAudioUnitParameterUnit_Generic + } +} + unsafe impl Send for Wrapper

{} -/// Public re-exports for the `nih_export_au!` macro. pub use Wrapper as AuWrapper; - -/// Hold a strong reference to the plugin metadata so the macro can build a -/// `static PLUGIN_INFO` and thread it through the factory function. -#[allow(dead_code)] -pub struct PluginRef(Arc<()>, PhantomData

); From 1a6704650b8fa2983a9caa171490e5d54923c520 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 12:36:41 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat(au):=20Phase=203=20=E2=80=94=20Plugi?= =?UTF-8?q?n::process=20render=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapper 가 Plugin lifecycle 와 통합: - Initialize selector: AudioIOLayout + BufferConfig 구성 후 Plugin::initialize → Plugin::reset 호출. 실패 시 kAudioUnitErr_FailedInitialization. - Uninitialize: Plugin::deactivate. - Reset selector: Plugin::reset 호출. Render selector 가 진짜 동작: - AudioBufferList → nih-plug Buffer (NonInterleaved per-channel slices) - Plugin::process(buffer, aux, ctx) 호출 - aux 는 빈 sidechain (Phase 3 미지원) - ProcessContext 는 stub (transport=defaults, MIDI 없음, latency 만 sink atomic 으로 백) - 초기화 전 render 호출 시 무음 fallback (zero_buffer_list) 추가: - src/wrapper/au/context.rs — AuInitContext, AuProcessContext, ContextSink - src/context.rs: PluginApi::Au variant + Display - AU 측 sample slice 의 lifetime 은 transmute 로 'static 으로 erase (render() 끝까지만 사용, Buffer 가 함수 로컬에서 소비) 다음 (Phase 4): Cocoa view via objc2-app-kit (egui-baseview NSView 통합). --- src/context.rs | 3 + src/wrapper/au.rs | 1 + src/wrapper/au/context.rs | 86 +++++++++++++++++ src/wrapper/au/wrapper.rs | 191 ++++++++++++++++++++++++++++++++++---- 4 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 src/wrapper/au/context.rs diff --git a/src/context.rs b/src/context.rs index fd98fee26..9aa844b4e 100644 --- a/src/context.rs +++ b/src/context.rs @@ -16,6 +16,8 @@ pub enum PluginApi { Clap, Standalone, Vst3, + /// Apple Audio Unit v2. Available with the `au` feature on macOS. + Au, } impl Display for PluginApi { @@ -24,6 +26,7 @@ impl Display for PluginApi { PluginApi::Clap => write!(f, "CLAP"), PluginApi::Standalone => write!(f, "standalone"), PluginApi::Vst3 => write!(f, "VST3"), + PluginApi::Au => write!(f, "AU"), } } } diff --git a/src/wrapper/au.rs b/src/wrapper/au.rs index 6a1db3634..23bca5684 100644 --- a/src/wrapper/au.rs +++ b/src/wrapper/au.rs @@ -11,6 +11,7 @@ //! Phase 4: Cocoa view via `objc2-app-kit`. //! Phase 5: bundle generation in `nih_plug_xtask`. +mod context; mod factory; mod wrapper; diff --git a/src/wrapper/au/context.rs b/src/wrapper/au/context.rs new file mode 100644 index 000000000..a97f7cd32 --- /dev/null +++ b/src/wrapper/au/context.rs @@ -0,0 +1,86 @@ +//! Minimal `InitContext` / `ProcessContext` impls for the AU wrapper. +//! +//! Phase 3 supplies just enough plumbing for `Plugin::initialize()` and +//! `Plugin::process()` to be called. Background tasks, MIDI events, voice +//! capacity, and latency feedback are stubbed — the AU wrapper does not yet +//! propagate them upstream. They become real in later phases as they are +//! needed by de-leak-rt or other plugins. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use crate::context::init::InitContext; +use crate::context::process::{ProcessContext, Transport}; +use crate::context::PluginApi; +use crate::prelude::PluginNoteEvent; +use crate::plugin::Plugin; + +/// Cell shared between the wrapper and its `InitContext` / `ProcessContext` +/// so that calls like `set_latency_samples()` can stash a value the wrapper +/// reads after `initialize()` / `process()` returns. +pub(super) struct ContextSink { + pub latency_samples: AtomicU32, +} + +impl ContextSink { + pub fn new() -> Arc { + Arc::new(Self { + latency_samples: AtomicU32::new(0), + }) + } +} + +pub(super) struct AuInitContext { + pub sink: Arc, + pub _marker: std::marker::PhantomData

, +} + +impl InitContext

for AuInitContext

{ + fn plugin_api(&self) -> PluginApi { + PluginApi::Au + } + + fn execute(&self, _task: P::BackgroundTask) { + // No background executor in Phase 3. + } + + fn set_latency_samples(&self, samples: u32) { + self.sink.latency_samples.store(samples, Ordering::Relaxed); + } + + fn set_current_voice_capacity(&self, _capacity: u32) { + // CLAP-only. + } +} + +pub(super) struct AuProcessContext { + pub sink: Arc, + pub transport: Transport, + pub _marker: std::marker::PhantomData

, +} + +impl ProcessContext

for AuProcessContext

{ + fn plugin_api(&self) -> PluginApi { + PluginApi::Au + } + + fn execute_background(&self, _task: P::BackgroundTask) {} + + fn execute_gui(&self, _task: P::BackgroundTask) {} + + fn transport(&self) -> &Transport { + &self.transport + } + + fn next_event(&mut self) -> Option> { + None + } + + fn send_event(&mut self, _event: PluginNoteEvent

) {} + + fn set_latency_samples(&self, samples: u32) { + self.sink.latency_samples.store(samples, Ordering::Relaxed); + } + + fn set_current_voice_capacity(&self, _capacity: u32) {} +} diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index d22615742..ad5eec72b 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -12,15 +12,23 @@ //! `Plugin::process()` path. use std::ffi::c_void; +use std::marker::PhantomData; +use std::num::NonZeroU32; use std::ptr; +use std::sync::atomic::Ordering; use std::sync::Arc; use au_sys as au; +use crate::buffer::Buffer; +use crate::context::process::Transport; use crate::params::internals::ParamPtr; use crate::params::Params; use crate::plugin::au::AuPlugin; use crate::plugin::Plugin; +use crate::prelude::{AudioIOLayout, AuxiliaryBuffers, BufferConfig, ProcessMode}; + +use super::context::{AuInitContext, AuProcessContext, ContextSink}; /// One AU plugin instance. Owned by Apple's component manager via the /// `AudioComponentPlugInInterface` pointer returned from the factory. @@ -49,7 +57,7 @@ pub struct Wrapper { /// The plugin instance. Kept for the entire lifetime of the wrapper so /// the `ParamPtr` raw pointers in `params_by_id` remain valid. - _plugin: Box

, + plugin: Box

, /// Parameter handles in declaration order. The AU parameter ID is the /// index into this vec. @@ -58,6 +66,14 @@ pub struct Wrapper { /// Strong reference back to the `Params` object referenced by every /// `ParamPtr` in `params_by_id`. _params_arc: Arc, + + /// Whether `Plugin::initialize()` has run successfully since the last + /// `Uninitialize` / first construction. Render is a no-op when false. + initialized: bool, + + /// Scratch sink shared between Init/Process contexts. Lets the plugin + /// report a latency change back via `set_latency_samples()`. + sink: Arc, } struct ParamEntry { @@ -90,9 +106,11 @@ impl Wrapper

{ max_frames_per_slice: 1024, n_channels: 2, latency_seconds: 0.0, - _plugin: plugin, + plugin, params_by_id, _params_arc: params_arc, + initialized: false, + sink: ContextSink::new(), }); Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface } @@ -160,19 +178,68 @@ impl Wrapper

{ Some(method) } - unsafe extern "C" fn initialize(_self_ptr: *mut c_void) -> au::OSStatus { + /// Called by the host once the audio configuration has been fully + /// queried — sample rate, stream format, max frames per slice are all + /// already set. Build the nih-plug `BufferConfig` / `AudioIOLayout` + /// from those and forward to `Plugin::initialize`. + unsafe extern "C" fn initialize(self_ptr: *mut c_void) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + + // Pick the first matching layout from the plugin's declared options. + // AU effects with mismatched in/out channels aren't supported in + // Phase 3 — we just use the host's stream-format channel count for + // both sides. + let chans = NonZeroU32::new(this.n_channels.max(1)); + let io_layout = AudioIOLayout { + main_input_channels: chans, + main_output_channels: chans, + ..AudioIOLayout::const_default() + }; + let buffer_config = BufferConfig { + sample_rate: this.sample_rate as f32, + min_buffer_size: None, + max_buffer_size: this.max_frames_per_slice, + process_mode: ProcessMode::Realtime, + }; + + let mut ctx = AuInitContext::

{ + sink: this.sink.clone(), + _marker: PhantomData, + }; + let ok = this.plugin.initialize(&io_layout, &buffer_config, &mut ctx); + if !ok { + return au::kAudioUnitErr_FailedInitialization; + } + + // `Plugin::initialize()` is always followed by `Plugin::reset()`. + this.plugin.reset(); + + // Pull the latency the plugin reported (if any). + let latency = this.sink.latency_samples.load(Ordering::Relaxed); + if latency > 0 && this.sample_rate > 0.0 { + this.latency_seconds = latency as f64 / this.sample_rate; + } + + this.initialized = true; au::noErr } - unsafe extern "C" fn uninitialize(_self_ptr: *mut c_void) -> au::OSStatus { + unsafe extern "C" fn uninitialize(self_ptr: *mut c_void) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + if this.initialized { + this.plugin.deactivate(); + this.initialized = false; + } au::noErr } unsafe extern "C" fn reset( - _self_ptr: *mut c_void, + self_ptr: *mut c_void, _scope: au::AudioUnitScope, _element: au::AudioUnitElement, ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + this.plugin.reset(); au::noErr } @@ -509,32 +576,99 @@ impl Wrapper

{ au::noErr } - /// Phase 2 render: still silence — Phase 3 will hook the real - /// `Plugin::process`. + /// Phase 3 render. Convert the host's `AudioBufferList` into the + /// nih-plug `Buffer` shape and dispatch to `Plugin::process`. + /// + /// The wrapper advertises non-interleaved float as the only stream + /// format (`AudioStreamBasicDescription` in `kAudioUnitProperty_StreamFormat`), + /// so each `AudioBuffer` in `io_data` corresponds to exactly one channel + /// (`mNumberChannels == 1`) holding `in_number_frames` `f32` samples. + /// We also advertise in-place processing, so for hosts that honour it + /// `io_data` already contains the input audio when render is called. + /// Hosts that disable in-place still pass output buffers — they're + /// expected to have copied the input ahead of time. unsafe extern "C" fn render( - _self_ptr: *mut c_void, + self_ptr: *mut c_void, _io_action_flags: *mut au::AudioUnitRenderActionFlags, _in_time_stamp: *const au::AudioTimeStamp, _in_output_bus_number: au::UInt32, in_number_frames: au::UInt32, io_data: *mut au::AudioBufferList, ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + if io_data.is_null() { return au::kAudioUnitErr_InvalidParameter; } - unsafe { - let bl = &*io_data; - let n_buffers = bl.mNumberBuffers as usize; - let buffers = bl.mBuffers.as_ptr(); - for i in 0..n_buffers { - let buf = &*buffers.add(i); - if buf.mData.is_null() { - continue; - } - let n_samples = in_number_frames as usize * buf.mNumberChannels as usize; - ptr::write_bytes(buf.mData as *mut f32, 0, n_samples); + + // Bail out cleanly if Initialize was never called: zero the buffers + // and return. This protects the plugin from being asked to process + // before sample rate / max-frames / channel count are known. + if !this.initialized { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + return au::noErr; + } + + let n_frames = in_number_frames as usize; + + // Collect mutable f32 slices for each channel directly out of the + // host's `AudioBufferList`. Each `AudioBuffer` is one channel + // (NonInterleaved stream format) with `mNumberChannels == 1`. + let bl = unsafe { &mut *io_data }; + let n_buffers = bl.mNumberBuffers as usize; + let buffers_ptr = bl.mBuffers.as_mut_ptr(); + + // We can't store these slices in a `Vec` *and* have them live long + // enough for `Buffer::set_slices` while the borrow checker is happy + // about the lifetime erasure that AU's C ABI demands. Instead: + // build a stack-local `Vec<&'static mut [f32]>` (lifetime-erased), + // pass it into `Buffer` via the `set_slices` closure, and ensure + // nothing in this function outlives the closure. + let mut channels: Vec<&'static mut [f32]> = Vec::with_capacity(n_buffers); + for i in 0..n_buffers { + let buf = unsafe { &mut *buffers_ptr.add(i) }; + if buf.mData.is_null() { + // Skip channels whose buffer the host didn't supply. + continue; } + // `mNumberChannels` is 1 for non-interleaved buffers; the + // sample count is `in_number_frames`. + let slice = unsafe { + std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) + }; + // SAFETY: erasing the lifetime is fine because the slice never + // outlives this `render()` call — `Buffer` is consumed locally. + let static_slice: &'static mut [f32] = unsafe { + std::mem::transmute::<&mut [f32], &'static mut [f32]>(slice) + }; + channels.push(static_slice); + } + + let mut buffer = Buffer::default(); + unsafe { + buffer.set_slices(n_frames, |dst| { + *dst = channels; + }); + } + + let mut aux = AuxiliaryBuffers { + inputs: &mut [], + outputs: &mut [], + }; + let mut process_ctx = AuProcessContext::

{ + sink: this.sink.clone(), + transport: Transport::new(this.sample_rate as f32), + _marker: PhantomData, + }; + + let _status = this.plugin.process(&mut buffer, &mut aux, &mut process_ctx); + + // Pull any latency change the plugin requested during process. + let latency = this.sink.latency_samples.load(Ordering::Relaxed); + if latency > 0 && this.sample_rate > 0.0 { + this.latency_seconds = latency as f64 / this.sample_rate; } + au::noErr } @@ -625,3 +759,22 @@ fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { unsafe impl Send for Wrapper

{} pub use Wrapper as AuWrapper; + +/// Write zeros into every non-null buffer in `bl`. Used as a safe fallback +/// when render is called before initialization has succeeded. +unsafe fn zero_buffer_list(bl: *mut au::AudioBufferList, n_frames: au::UInt32) { + if bl.is_null() { + return; + } + let bl = unsafe { &mut *bl }; + let n = bl.mNumberBuffers as usize; + let buffers_ptr = bl.mBuffers.as_mut_ptr(); + for i in 0..n { + let buf = unsafe { &mut *buffers_ptr.add(i) }; + if buf.mData.is_null() { + continue; + } + let n_samples = n_frames as usize * buf.mNumberChannels as usize; + unsafe { ptr::write_bytes(buf.mData as *mut f32, 0, n_samples) }; + } +} From 4b782b88e36818fb60c7f9f00543924c36746880 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 13:38:11 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat(au):=20Phase=203.5=20=E2=80=94=20inp?= =?UTF-8?q?ut=20pull=20via=20render=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 의 silence 출력 버그 fix. 문제: Plugin::process 호출은 됐지만 io_data 에 input 이 없어서 plugin 이 "input=0 → 처리해도 0" 반환. 호스트 (Ableton 등) 는 input bus 에 SetRenderCallback 으로 callback 등록 → unit 이 그 callback 호출해서 input 받는 pull model 사용. 우리는 SetRenderCallback property 에서 noErr 만 반환하고 callback 자체를 잃어버렸음. 수정: - Wrapper 에 input_callback (Option) + input_scratch (Vec>) 추가 - set_property 의 kAudioUnitProperty_SetRenderCallback 가 callback 보존 - Initialize 가 input_scratch 를 n_channels × max_frames_per_slice 로 alloc (RT-safe — render path 에서 추가 alloc 없음) - Render 시작 시 input_callback 등록되어있으면: - n_channels 만큼의 AudioBuffer 가 들어가는 stack-local AudioBufferList 구성 (mBuffers 가 [AudioBuffer; 1] variable-length 라 raw bytes alloc) - 호스트 callback 호출 → input_scratch 채워짐 - io_data 에 copy → Plugin::process 가 in-place 처리 - Callback 없을 때는 io_data 자체를 in-place 사용 (InPlaceProcessing 광고대로) 진단 로그 (eprintln, 첫 5 호출만): pulled / cb_set / rms_in / max_in. Console.app 의 "NIH-AU" 필터로 동작 검증. --- src/wrapper/au/wrapper.rs | 170 ++++++++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 15 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index ad5eec72b..66e04a7a8 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -74,6 +74,21 @@ pub struct Wrapper { /// Scratch sink shared between Init/Process contexts. Lets the plugin /// report a latency change back via `set_latency_samples()`. sink: Arc, + + /// Input render callback registered by the host via + /// `kAudioUnitProperty_SetRenderCallback`. We invoke this at the top of + /// every `render()` call to pull input audio for the effect. + /// + /// Hosts that don't use the callback model (or for in-place processing) + /// leave this `None` and we just operate on whatever the host wrote + /// into `io_data` ahead of the call. + input_callback: Option, + + /// Per-channel scratch buffers for the input pull. Sized in `Initialize` + /// to `[max_frames_per_slice]` so the render path is allocation-free. + /// One `Vec` per channel; we hand the host a synthesised + /// `AudioBufferList` whose `mData` pointers point into these vectors. + input_scratch: Vec>, } struct ParamEntry { @@ -111,6 +126,8 @@ impl Wrapper

{ _params_arc: params_arc, initialized: false, sink: ContextSink::new(), + input_callback: None, + input_scratch: Vec::new(), }); Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface } @@ -214,6 +231,17 @@ impl Wrapper

{ // `Plugin::initialize()` is always followed by `Plugin::reset()`. this.plugin.reset(); + // Allocate input scratch space: one Vec per channel, each + // sized to max_frames_per_slice so the render path is allocation + // free. + let nch = this.n_channels.max(1) as usize; + let cap = this.max_frames_per_slice as usize; + this.input_scratch.clear(); + this.input_scratch.reserve(nch); + for _ in 0..nch { + this.input_scratch.push(vec![0.0_f32; cap]); + } + // Pull the latency the plugin reported (if any). let latency = this.sink.latency_samples.load(Ordering::Relaxed); if latency > 0 && this.sample_rate > 0.0 { @@ -523,7 +551,21 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_BypassEffect => au::noErr, - au::kAudioUnitProperty_SetRenderCallback => au::noErr, + au::kAudioUnitProperty_SetRenderCallback => { + if (in_data_size as usize) + < std::mem::size_of::() + { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let cb = unsafe { *(in_data as *const au::AURenderCallbackStruct) }; + // Ignore null callbacks (host clearing the slot). + this.input_callback = if cb.inputProc.is_some() { + Some(cb) + } else { + None + }; + au::noErr + } au::kAudioUnitProperty_InPlaceProcessing => au::noErr, _ => au::kAudioUnitErr_InvalidProperty, } @@ -609,35 +651,133 @@ impl Wrapper

{ return au::noErr; } - let n_frames = in_number_frames as usize; + let n_frames_us = in_number_frames as usize; + + // ── 1) Pull input from the host. ───────────────────────────────── + // If the host registered a SetRenderCallback for our input bus, + // call it with a synthesised AudioBufferList whose buffers point + // into `input_scratch`. After the call, `input_scratch[ch][..n_frames]` + // holds the channel data we need to feed the plugin. + // + // If no callback was registered, we operate "in place" on whatever + // the host wrote into `io_data` directly (as advertised via the + // InPlaceProcessing property). For Ableton, Logic, Reaper this + // path is the common one. + let mut pulled_from_callback = false; + if let Some(cb) = this.input_callback { + // Build an AudioBufferList in scratch memory big enough for + // `n_channels` buffers. The struct has a single `[AudioBuffer; 1]` + // tail array; for >1 channel we need extra storage. + let n_ch = this.input_scratch.len(); + if n_ch > 0 && this.input_scratch[0].len() >= n_frames_us { + let header_size = std::mem::size_of::(); // mNumberBuffers + let buf_size = std::mem::size_of::(); + let bl_bytes = header_size + buf_size * n_ch; + let mut bl_storage: Vec = vec![0u8; bl_bytes]; + let bl_ptr = bl_storage.as_mut_ptr() as *mut au::AudioBufferList; + unsafe { + (*bl_ptr).mNumberBuffers = n_ch as au::UInt32; + } + let buffers_ptr = unsafe { + (bl_storage.as_mut_ptr().add(header_size)) as *mut au::AudioBuffer + }; + for ch in 0..n_ch { + let scratch_ptr = this.input_scratch[ch].as_mut_ptr(); + unsafe { + *buffers_ptr.add(ch) = au::AudioBuffer { + mNumberChannels: 1, + mDataByteSize: (n_frames_us * std::mem::size_of::()) as au::UInt32, + mData: scratch_ptr as *mut c_void, + }; + } + } - // Collect mutable f32 slices for each channel directly out of the - // host's `AudioBufferList`. Each `AudioBuffer` is one channel - // (NonInterleaved stream format) with `mNumberChannels == 1`. + // Build a default AudioTimeStamp (zero is fine for offline-style + // pull). The host inputProc fills it; we provide a zeroed one. + let ts: au::AudioTimeStamp = unsafe { std::mem::zeroed() }; + let mut flags: au::AudioUnitRenderActionFlags = 0; + let proc = cb.inputProc.unwrap(); + let status = unsafe { + proc( + cb.inputProcRefCon, + &mut flags, + &ts, + 0, // input bus number + in_number_frames, + bl_ptr, + ) + }; + if status == au::noErr { + pulled_from_callback = true; + } + } + } + + let n_frames = n_frames_us; + + // ── 2) Build process buffer slices. ────────────────────────────── + // Strategy: nih-plug's Plugin::process operates in place on its + // `Buffer` slices, so we always feed it slices into the output + // (io_data) buffers. If we pulled input via the host callback into + // `input_scratch`, copy that into io_data first; otherwise the host + // already provided in-place audio there. let bl = unsafe { &mut *io_data }; let n_buffers = bl.mNumberBuffers as usize; let buffers_ptr = bl.mBuffers.as_mut_ptr(); - // We can't store these slices in a `Vec` *and* have them live long - // enough for `Buffer::set_slices` while the borrow checker is happy - // about the lifetime erasure that AU's C ABI demands. Instead: - // build a stack-local `Vec<&'static mut [f32]>` (lifetime-erased), - // pass it into `Buffer` via the `set_slices` closure, and ensure - // nothing in this function outlives the closure. + // If host pulled via callback, copy scratch → io_data so process() sees input. + if pulled_from_callback { + for i in 0..n_buffers.min(this.input_scratch.len()) { + let buf = unsafe { &mut *buffers_ptr.add(i) }; + if buf.mData.is_null() { + continue; + } + let dst = + unsafe { std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) }; + let src = &this.input_scratch[i][..n_frames]; + dst.copy_from_slice(src); + } + } + + // Diagnostic: input RMS *after* the pull/copy step, gated to first 5 calls. + unsafe { + static mut DBG: u32 = 0; + if DBG < 5 { + let mut sum = 0.0_f64; + let mut max = 0.0_f32; + let mut cnt = 0_u64; + for i in 0..n_buffers { + let buf = &*buffers_ptr.add(i); + if buf.mData.is_null() { continue; } + let s = std::slice::from_raw_parts(buf.mData as *const f32, n_frames); + for v in s.iter() { + sum += (*v as f64) * (*v as f64); + let a = v.abs(); + if a > max { max = a; } + cnt += 1; + } + } + let rms = if cnt > 0 { (sum / cnt as f64).sqrt() } else { 0.0 }; + eprintln!( + "[NIH-AU] render n_frames={} buf_count={} pulled={} cb_set={} rms_in={:.6} max_in={:.6}", + in_number_frames, n_buffers, pulled_from_callback, + this.input_callback.is_some(), rms, max + ); + DBG += 1; + } + } + let mut channels: Vec<&'static mut [f32]> = Vec::with_capacity(n_buffers); for i in 0..n_buffers { let buf = unsafe { &mut *buffers_ptr.add(i) }; if buf.mData.is_null() { - // Skip channels whose buffer the host didn't supply. continue; } - // `mNumberChannels` is 1 for non-interleaved buffers; the - // sample count is `in_number_frames`. let slice = unsafe { std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) }; // SAFETY: erasing the lifetime is fine because the slice never - // outlives this `render()` call — `Buffer` is consumed locally. + // outlives this render() call — Buffer is consumed locally. let static_slice: &'static mut [f32] = unsafe { std::mem::transmute::<&mut [f32], &'static mut [f32]>(slice) }; From 6a6014d20db9f05dafc9679e670b3e9eb25cdbb0 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 18:04:18 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat(au):=20AUD-332=20=E2=80=94=20&mut=20?= =?UTF-8?q?Self=20aliasing=20UB=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapper 동시성 모델 재설계. - from_ptr → &Self 반환. main(SetProperty/SetParameter)과 audio(Render/SetParameter) 스레드가 동시에 wrapper에 진입해도 &mut 두 개가 동시에 살아있을 일 없음. - 가변 필드 분리: * 호스트 setup (sample_rate, n_channels, max_frames_per_slice, latency_seconds, initialized) → AtomicU32/AtomicU64/AtomicBool * audio-thread 전용 (plugin, input_scratch) → UnsafeCell, render() 안에서만 &mut 빌림. AU는 render 재진입 금지 + Initialize/Uninitialize/Reset과 Render 동시 실행 금지를 보장. * main↔audio 공유 (input_callback) → Mutex>, render는 시작 시점에 스냅샷만 떠서 락을 즉시 해제. - unsafe impl Send + Sync 둘 다, 정당화 주석과 함께 추가. - 모듈 doc을 Phase 3.5 + 동시성 모델로 갱신. DoD: - cargo build --features au 성공, AU wrapper 자체 경고 0건. - cargo build --features au,assert_process_allocs 성공. - grep 결과 from_ptr가 &Self만 반환, &mut Self 인스턴스 0건. 남은 alloc/transmute는 AUD-331 / AUD-333에서 처리. --- src/wrapper/au/wrapper.rs | 415 ++++++++++++++++++++++---------------- 1 file changed, 245 insertions(+), 170 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 66e04a7a8..6f69a8bb1 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1,4 +1,4 @@ -//! AU wrapper, Phase 2. +//! AU wrapper, Phase 3.5. //! //! Hosts the plugin's `Params` so: //! - `kAudioUnitProperty_ParameterList` returns the AU parameter ID array @@ -8,15 +8,36 @@ //! AU parameter IDs are simply the index into `param_map()` — stable across //! one binary build (the nih-plug derive guarantees field declaration order). //! -//! Render is still Phase 1 (silence). Phase 3 will wire the actual -//! `Plugin::process()` path. +//! # Concurrency model +//! +//! AU hosts call us from at least two threads concurrently: +//! - **main thread** — `SetProperty`, `GetProperty`, `SetParameter` (UI), +//! `Initialize` / `Uninitialize` +//! - **audio thread** — `Render`, `SetParameter` (sample-accurate automation) +//! +//! The wrapper exposes only `&Self` from `from_ptr` so we can never construct +//! two `&mut Self` simultaneously. Mutable state is split into three buckets: +//! +//! 1. **host-setup atomics** — `sample_rate`, `n_channels`, +//! `max_frames_per_slice`, `latency_seconds`, `initialized`. Written by +//! main thread before `Initialize`, read by the audio thread thereafter. +//! `AtomicU32`/`AtomicU64` (latency/sr packed as f64 bits). +//! +//! 2. **audio-thread-owned scratch** — `input_scratch`. Inside `UnsafeCell`, +//! 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. +use std::cell::UnsafeCell; use std::ffi::c_void; use std::marker::PhantomData; use std::num::NonZeroU32; use std::ptr; -use std::sync::atomic::Ordering; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use au_sys as au; @@ -25,7 +46,6 @@ use crate::context::process::Transport; use crate::params::internals::ParamPtr; use crate::params::Params; use crate::plugin::au::AuPlugin; -use crate::plugin::Plugin; use crate::prelude::{AudioIOLayout, AuxiliaryBuffers, BufferConfig, ProcessMode}; use super::context::{AuInitContext, AuProcessContext, ContextSink}; @@ -40,37 +60,46 @@ pub struct Wrapper { /// Apple-required vtable. MUST be the first field. vtable: au::AudioComponentPlugInInterface, - /// Host's `AudioUnit` opaque handle, set in `Open`. - instance: au::AudioUnit, + /// Host's `AudioUnit` opaque handle, set in `Open` (main thread, once). + /// Stored as raw ptr behind atomic so we can read from any thread. + instance: AtomicU64, - /// Sample rate set via `kAudioUnitProperty_StreamFormat`. - sample_rate: f64, + /// Sample rate set via `kAudioUnitProperty_StreamFormat`. f64 bits. + sample_rate_bits: AtomicU64, /// Maximum frames per `AudioUnitRender` slice. - max_frames_per_slice: u32, + max_frames_per_slice: AtomicU32, /// Channel count set via `StreamFormat`. - n_channels: u32, + n_channels: AtomicU32, + + /// Latency reported via `kAudioUnitProperty_Latency`. f64 bits. + latency_seconds_bits: AtomicU64, - /// Latency reported via `kAudioUnitProperty_Latency`. - latency_seconds: f64, + /// Whether `Plugin::initialize()` has run successfully since the last + /// `Uninitialize` / first construction. Render is a no-op when false. + initialized: AtomicBool, /// The plugin instance. Kept for the entire lifetime of the wrapper so /// the `ParamPtr` raw pointers in `params_by_id` remain valid. - plugin: Box

, + /// + /// Wrapped in `UnsafeCell` because `Plugin::initialize` / `process` / + /// `reset` / `deactivate` take `&mut self`, but we only ever expose `&Self` + /// from `from_ptr`. AU's threading model guarantees these methods are not + /// concurrent with each other (Initialize/Uninitialize/Reset run on main, + /// process runs on audio thread, and the host serialises lifecycle vs. + /// render via `Initialize`/`Uninitialize` boundaries — render can only + /// happen between them). + plugin: UnsafeCell>, /// Parameter handles in declaration order. The AU parameter ID is the - /// index into this vec. + /// index into this vec. Built once in `new()`, then read-only. params_by_id: Vec, /// Strong reference back to the `Params` object referenced by every /// `ParamPtr` in `params_by_id`. _params_arc: Arc, - /// Whether `Plugin::initialize()` has run successfully since the last - /// `Uninitialize` / first construction. Render is a no-op when false. - initialized: bool, - /// Scratch sink shared between Init/Process contexts. Lets the plugin /// report a latency change back via `set_latency_samples()`. sink: Arc, @@ -79,16 +108,18 @@ pub struct Wrapper { /// `kAudioUnitProperty_SetRenderCallback`. We invoke this at the top of /// every `render()` call to pull input audio for the effect. /// - /// Hosts that don't use the callback model (or for in-place processing) - /// leave this `None` and we just operate on whatever the host wrote - /// into `io_data` ahead of the call. - input_callback: Option, + /// `AURenderCallbackStruct` is `Copy`. Audio thread snapshots out under + /// the mutex at the start of render and releases immediately. + input_callback: Mutex>, /// Per-channel scratch buffers for the input pull. Sized in `Initialize` /// to `[max_frames_per_slice]` so the render path is allocation-free. /// One `Vec` per channel; we hand the host a synthesised /// `AudioBufferList` whose `mData` pointers point into these vectors. - input_scratch: Vec>, + /// + /// `UnsafeCell` because only `render()` touches it after `Initialize`, + /// and AU does not re-enter render. + input_scratch: UnsafeCell>>, } struct ParamEntry { @@ -98,6 +129,30 @@ struct ParamEntry { ptr: ParamPtr, } +/// `Send` + `Sync` justification: +/// +/// - All publicly mutable fields are atomic or behind a `Mutex`. +/// - `plugin` and `input_scratch` are `UnsafeCell`-wrapped, accessed only +/// through `&mut` borrows constructed during a single audio-thread render +/// call (`input_scratch`) or during main-thread lifecycle calls that AU +/// serialises against render (`plugin`). The host contract forbids +/// `Initialize` / `Uninitialize` / `Reset` concurrent with `Render`. +/// - `ParamPtr` in `params_by_id` is built once and read-only thereafter; +/// per nih-plug contract, `set_normalized_value` and the value getters +/// are themselves thread-safe. +/// - The `vtable` and `_params_arc` are immutable after construction. +unsafe impl Send for Wrapper

{} +unsafe impl Sync for Wrapper

{} + +#[inline] +fn pack_f64(v: f64) -> u64 { + v.to_bits() +} +#[inline] +fn unpack_f64(b: u64) -> f64 { + f64::from_bits(b) +} + impl Wrapper

{ pub fn new() -> *mut au::AudioComponentPlugInInterface { let plugin = Box::new(P::default()); @@ -116,29 +171,90 @@ impl Wrapper

{ Lookup: Self::lookup, reserved: ptr::null_mut(), }, - instance: ptr::null_mut(), - sample_rate: 44_100.0, - max_frames_per_slice: 1024, - n_channels: 2, - latency_seconds: 0.0, - plugin, + instance: AtomicU64::new(0), + sample_rate_bits: AtomicU64::new(pack_f64(44_100.0)), + max_frames_per_slice: AtomicU32::new(1024), + n_channels: AtomicU32::new(2), + latency_seconds_bits: AtomicU64::new(pack_f64(0.0)), + initialized: AtomicBool::new(false), + plugin: UnsafeCell::new(plugin), params_by_id, _params_arc: params_arc, - initialized: false, sink: ContextSink::new(), - input_callback: None, - input_scratch: Vec::new(), + input_callback: Mutex::new(None), + input_scratch: UnsafeCell::new(Vec::new()), }); Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface } - unsafe fn from_ptr<'a>(ptr: *mut c_void) -> &'a mut Self { - &mut *(ptr as *mut Self) + /// Returns a shared reference to the wrapper. We never hand out `&mut Self` + /// because AU's main + audio threads can both be inside the wrapper at + /// the same time and `&mut Self` aliasing would be UB. + #[inline] + unsafe fn from_ptr<'a>(ptr: *mut c_void) -> &'a Self { + unsafe { &*(ptr as *const Self) } } + // ───────────────────────────────────────────────────────────────────── + // Field accessors (atomic) + // ───────────────────────────────────────────────────────────────────── + + #[inline] + fn sample_rate(&self) -> f64 { + unpack_f64(self.sample_rate_bits.load(Ordering::Acquire)) + } + #[inline] + fn set_sample_rate(&self, sr: f64) { + self.sample_rate_bits.store(pack_f64(sr), Ordering::Release); + } + #[inline] + fn latency_seconds(&self) -> f64 { + unpack_f64(self.latency_seconds_bits.load(Ordering::Acquire)) + } + #[inline] + fn set_latency_seconds(&self, l: f64) { + self.latency_seconds_bits + .store(pack_f64(l), Ordering::Release); + } + #[inline] + fn n_channels(&self) -> u32 { + self.n_channels.load(Ordering::Acquire) + } + #[inline] + fn max_frames_per_slice(&self) -> u32 { + self.max_frames_per_slice.load(Ordering::Acquire) + } + #[inline] + fn is_initialized(&self) -> bool { + self.initialized.load(Ordering::Acquire) + } + + /// SAFETY: caller must guarantee no other reference (mut or shared) to + /// `*self.plugin.get()` is alive. AU's contract gives this for the + /// `Initialize` / `Uninitialize` / `Reset` / `Render` paths because the + /// host serialises lifecycle calls vs. render and never re-enters render. + #[inline] + #[allow(clippy::mut_from_ref)] + unsafe fn plugin_mut(&self) -> &mut P { + unsafe { &mut **self.plugin.get() } + } + + /// SAFETY: same as `plugin_mut` — only call from `render()`. Returns the + /// scratch vector for in-place mutation by the audio thread. + #[inline] + #[allow(clippy::mut_from_ref)] + unsafe fn input_scratch_mut(&self) -> &mut Vec> { + unsafe { &mut *self.input_scratch.get() } + } + + // ───────────────────────────────────────────────────────────────────── + // Component manager vtable + // ───────────────────────────────────────────────────────────────────── + unsafe extern "C" fn open(self_ptr: *mut c_void, instance: au::AudioUnit) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; - this.instance = instance; + this.instance + .store(instance as usize as u64, Ordering::Release); au::noErr } @@ -202,20 +318,19 @@ impl Wrapper

{ unsafe extern "C" fn initialize(self_ptr: *mut c_void) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; - // Pick the first matching layout from the plugin's declared options. - // AU effects with mismatched in/out channels aren't supported in - // Phase 3 — we just use the host's stream-format channel count for - // both sides. - let chans = NonZeroU32::new(this.n_channels.max(1)); + let n_ch = this.n_channels().max(1); + let chans = NonZeroU32::new(n_ch); let io_layout = AudioIOLayout { main_input_channels: chans, main_output_channels: chans, ..AudioIOLayout::const_default() }; + let max_frames = this.max_frames_per_slice(); + let sr = this.sample_rate(); let buffer_config = BufferConfig { - sample_rate: this.sample_rate as f32, + sample_rate: sr as f32, min_buffer_size: None, - max_buffer_size: this.max_frames_per_slice, + max_buffer_size: max_frames, process_mode: ProcessMode::Realtime, }; @@ -223,40 +338,43 @@ impl Wrapper

{ sink: this.sink.clone(), _marker: PhantomData, }; - let ok = this.plugin.initialize(&io_layout, &buffer_config, &mut ctx); + // SAFETY: AU forbids Initialize concurrent with Render. + let plugin = unsafe { this.plugin_mut() }; + let ok = plugin.initialize(&io_layout, &buffer_config, &mut ctx); if !ok { return au::kAudioUnitErr_FailedInitialization; } - - // `Plugin::initialize()` is always followed by `Plugin::reset()`. - this.plugin.reset(); + plugin.reset(); // Allocate input scratch space: one Vec per channel, each // sized to max_frames_per_slice so the render path is allocation // free. - let nch = this.n_channels.max(1) as usize; - let cap = this.max_frames_per_slice as usize; - this.input_scratch.clear(); - this.input_scratch.reserve(nch); - for _ in 0..nch { - this.input_scratch.push(vec![0.0_f32; cap]); + // SAFETY: same — render is serialised vs. Initialize. + let scratch = unsafe { this.input_scratch_mut() }; + scratch.clear(); + scratch.reserve(n_ch as usize); + for _ in 0..n_ch as usize { + scratch.push(vec![0.0_f32; max_frames as usize]); } - // Pull the latency the plugin reported (if any). let latency = this.sink.latency_samples.load(Ordering::Relaxed); - if latency > 0 && this.sample_rate > 0.0 { - this.latency_seconds = latency as f64 / this.sample_rate; + if latency > 0 && sr > 0.0 { + this.set_latency_seconds(latency as f64 / sr); } - this.initialized = true; + this.initialized.store(true, Ordering::Release); au::noErr } unsafe extern "C" fn uninitialize(self_ptr: *mut c_void) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; - if this.initialized { - this.plugin.deactivate(); - this.initialized = false; + if this + .initialized + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + // SAFETY: AU forbids Uninitialize concurrent with Render. + unsafe { this.plugin_mut() }.deactivate(); } au::noErr } @@ -267,7 +385,8 @@ impl Wrapper

{ _element: au::AudioUnitElement, ) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; - this.plugin.reset(); + // SAFETY: AU calls Reset on the main thread, serialised against render. + unsafe { this.plugin_mut() }.reset(); au::noErr } @@ -375,7 +494,7 @@ impl Wrapper

{ return au::kAudioUnitErr_InvalidPropertyValue; } unsafe { - *(out_data as *mut au::Float64) = this.sample_rate; + *(out_data as *mut au::Float64) = this.sample_rate(); *io_data_size = std::mem::size_of::() as u32; } au::noErr @@ -394,7 +513,7 @@ impl Wrapper

{ } au::kAudioUnitProperty_Latency if scope == au::kAudioUnitScope_Global => { unsafe { - *(out_data as *mut au::Float64) = this.latency_seconds; + *(out_data as *mut au::Float64) = this.latency_seconds(); *io_data_size = std::mem::size_of::() as u32; } au::noErr @@ -408,7 +527,7 @@ impl Wrapper

{ } au::kAudioUnitProperty_MaximumFramesPerSlice => { unsafe { - *(out_data as *mut au::UInt32) = this.max_frames_per_slice; + *(out_data as *mut au::UInt32) = this.max_frames_per_slice(); *io_data_size = std::mem::size_of::() as u32; } au::noErr @@ -420,7 +539,7 @@ impl Wrapper

{ return au::kAudioUnitErr_InvalidPropertyValue; } let asbd = au::AudioStreamBasicDescription { - mSampleRate: this.sample_rate, + mSampleRate: this.sample_rate(), mFormatID: au::kAudioFormatLinearPCM, mFormatFlags: au::kAudioFormatFlagIsFloat | au::kAudioFormatFlagIsPacked @@ -428,7 +547,7 @@ impl Wrapper

{ mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, - mChannelsPerFrame: this.n_channels, + mChannelsPerFrame: this.n_channels(), mBitsPerChannel: 32, mReserved: 0, }; @@ -529,7 +648,8 @@ impl Wrapper

{ if (in_data_size as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } - this.sample_rate = unsafe { *(in_data as *const au::Float64) }; + let sr = unsafe { *(in_data as *const au::Float64) }; + this.set_sample_rate(sr); au::noErr } au::kAudioUnitProperty_StreamFormat => { @@ -539,15 +659,17 @@ impl Wrapper

{ return au::kAudioUnitErr_InvalidPropertyValue; } let asbd = unsafe { &*(in_data as *const au::AudioStreamBasicDescription) }; - this.sample_rate = asbd.mSampleRate; - this.n_channels = asbd.mChannelsPerFrame; + this.set_sample_rate(asbd.mSampleRate); + this.n_channels + .store(asbd.mChannelsPerFrame, Ordering::Release); au::noErr } au::kAudioUnitProperty_MaximumFramesPerSlice => { if (in_data_size as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } - this.max_frames_per_slice = unsafe { *(in_data as *const au::UInt32) }; + let v = unsafe { *(in_data as *const au::UInt32) }; + this.max_frames_per_slice.store(v, Ordering::Release); au::noErr } au::kAudioUnitProperty_BypassEffect => au::noErr, @@ -558,12 +680,10 @@ impl Wrapper

{ return au::kAudioUnitErr_InvalidPropertyValue; } let cb = unsafe { *(in_data as *const au::AURenderCallbackStruct) }; - // Ignore null callbacks (host clearing the slot). - this.input_callback = if cb.inputProc.is_some() { - Some(cb) - } else { - None - }; + let new_cb = if cb.inputProc.is_some() { Some(cb) } else { None }; + if let Ok(mut guard) = this.input_callback.lock() { + *guard = new_cb; + } au::noErr } au::kAudioUnitProperty_InPlaceProcessing => au::noErr, @@ -621,14 +741,9 @@ impl Wrapper

{ /// Phase 3 render. Convert the host's `AudioBufferList` into the /// nih-plug `Buffer` shape and dispatch to `Plugin::process`. /// - /// The wrapper advertises non-interleaved float as the only stream - /// format (`AudioStreamBasicDescription` in `kAudioUnitProperty_StreamFormat`), - /// so each `AudioBuffer` in `io_data` corresponds to exactly one channel - /// (`mNumberChannels == 1`) holding `in_number_frames` `f32` samples. - /// We also advertise in-place processing, so for hosts that honour it - /// `io_data` already contains the input audio when render is called. - /// Hosts that disable in-place still pass output buffers — they're - /// expected to have copied the input ahead of time. + /// Concurrency: this runs on the audio thread. We only touch the wrapper + /// through `&Self` plus controlled `UnsafeCell` borrows for the things + /// the audio thread owns (`plugin`, `input_scratch`). unsafe extern "C" fn render( self_ptr: *mut c_void, _io_action_flags: *mut au::AudioUnitRenderActionFlags, @@ -643,34 +758,34 @@ impl Wrapper

{ return au::kAudioUnitErr_InvalidParameter; } - // Bail out cleanly if Initialize was never called: zero the buffers - // and return. This protects the plugin from being asked to process - // before sample rate / max-frames / channel count are known. - if !this.initialized { + if !this.is_initialized() { unsafe { zero_buffer_list(io_data, in_number_frames) }; return au::noErr; } let n_frames_us = in_number_frames as usize; - // ── 1) Pull input from the host. ───────────────────────────────── - // If the host registered a SetRenderCallback for our input bus, - // call it with a synthesised AudioBufferList whose buffers point - // into `input_scratch`. After the call, `input_scratch[ch][..n_frames]` - // holds the channel data we need to feed the plugin. - // - // If no callback was registered, we operate "in place" on whatever - // the host wrote into `io_data` directly (as advertised via the - // InPlaceProcessing property). For Ableton, Logic, Reaper this - // path is the common one. + // Snapshot the input callback under the mutex (cheap struct copy). + // Held only for the duration of the load so main-thread updates + // never block render for long. Note: the lock itself can technically + // contend with main thread; this is addressed in AUD-337 via a + // lock-free swap. For now, the only writer is SetRenderCallback + // which is rare. + let callback_snapshot = this + .input_callback + .lock() + .ok() + .and_then(|g| *g); + + // ── 1) Pull input from the host (callback path). ───────────────── + // SAFETY: render is not re-entered, so the audio thread is the sole + // owner of input_scratch right now. + let input_scratch = unsafe { this.input_scratch_mut() }; let mut pulled_from_callback = false; - if let Some(cb) = this.input_callback { - // Build an AudioBufferList in scratch memory big enough for - // `n_channels` buffers. The struct has a single `[AudioBuffer; 1]` - // tail array; for >1 channel we need extra storage. - let n_ch = this.input_scratch.len(); - if n_ch > 0 && this.input_scratch[0].len() >= n_frames_us { - let header_size = std::mem::size_of::(); // mNumberBuffers + if let Some(cb) = callback_snapshot { + let n_ch = input_scratch.len(); + if n_ch > 0 && input_scratch[0].len() >= n_frames_us { + let header_size = std::mem::size_of::(); let buf_size = std::mem::size_of::(); let bl_bytes = header_size + buf_size * n_ch; let mut bl_storage: Vec = vec![0u8; bl_bytes]; @@ -679,21 +794,20 @@ impl Wrapper

{ (*bl_ptr).mNumberBuffers = n_ch as au::UInt32; } let buffers_ptr = unsafe { - (bl_storage.as_mut_ptr().add(header_size)) as *mut au::AudioBuffer + bl_storage.as_mut_ptr().add(header_size) as *mut au::AudioBuffer }; for ch in 0..n_ch { - let scratch_ptr = this.input_scratch[ch].as_mut_ptr(); + let scratch_ptr = input_scratch[ch].as_mut_ptr(); unsafe { *buffers_ptr.add(ch) = au::AudioBuffer { mNumberChannels: 1, - mDataByteSize: (n_frames_us * std::mem::size_of::()) as au::UInt32, + mDataByteSize: (n_frames_us * std::mem::size_of::()) + as au::UInt32, mData: scratch_ptr as *mut c_void, }; } } - // Build a default AudioTimeStamp (zero is fine for offline-style - // pull). The host inputProc fills it; we provide a zeroed one. let ts: au::AudioTimeStamp = unsafe { std::mem::zeroed() }; let mut flags: au::AudioUnitRenderActionFlags = 0; let proc = cb.inputProc.unwrap(); @@ -702,7 +816,7 @@ impl Wrapper

{ cb.inputProcRefCon, &mut flags, &ts, - 0, // input bus number + 0, in_number_frames, bl_ptr, ) @@ -715,58 +829,27 @@ impl Wrapper

{ let n_frames = n_frames_us; - // ── 2) Build process buffer slices. ────────────────────────────── - // Strategy: nih-plug's Plugin::process operates in place on its - // `Buffer` slices, so we always feed it slices into the output - // (io_data) buffers. If we pulled input via the host callback into - // `input_scratch`, copy that into io_data first; otherwise the host - // already provided in-place audio there. + // ── 2) Build process buffer slices over io_data. ───────────────── let bl = unsafe { &mut *io_data }; let n_buffers = bl.mNumberBuffers as usize; let buffers_ptr = bl.mBuffers.as_mut_ptr(); - // If host pulled via callback, copy scratch → io_data so process() sees input. + // If we pulled via callback, copy scratch → io_data so process() + // sees the input audio. if pulled_from_callback { - for i in 0..n_buffers.min(this.input_scratch.len()) { + for i in 0..n_buffers.min(input_scratch.len()) { let buf = unsafe { &mut *buffers_ptr.add(i) }; if buf.mData.is_null() { continue; } - let dst = - unsafe { std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) }; - let src = &this.input_scratch[i][..n_frames]; + let dst = unsafe { + std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) + }; + let src = &input_scratch[i][..n_frames]; dst.copy_from_slice(src); } } - // Diagnostic: input RMS *after* the pull/copy step, gated to first 5 calls. - unsafe { - static mut DBG: u32 = 0; - if DBG < 5 { - let mut sum = 0.0_f64; - let mut max = 0.0_f32; - let mut cnt = 0_u64; - for i in 0..n_buffers { - let buf = &*buffers_ptr.add(i); - if buf.mData.is_null() { continue; } - let s = std::slice::from_raw_parts(buf.mData as *const f32, n_frames); - for v in s.iter() { - sum += (*v as f64) * (*v as f64); - let a = v.abs(); - if a > max { max = a; } - cnt += 1; - } - } - let rms = if cnt > 0 { (sum / cnt as f64).sqrt() } else { 0.0 }; - eprintln!( - "[NIH-AU] render n_frames={} buf_count={} pulled={} cb_set={} rms_in={:.6} max_in={:.6}", - in_number_frames, n_buffers, pulled_from_callback, - this.input_callback.is_some(), rms, max - ); - DBG += 1; - } - } - let mut channels: Vec<&'static mut [f32]> = Vec::with_capacity(n_buffers); for i in 0..n_buffers { let buf = unsafe { &mut *buffers_ptr.add(i) }; @@ -776,8 +859,9 @@ impl Wrapper

{ let slice = unsafe { std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) }; - // SAFETY: erasing the lifetime is fine because the slice never - // outlives this render() call — Buffer is consumed locally. + // SAFETY: erasing the lifetime is unsound in general; tracked in + // AUD-333. The slice never outlives this render() call and the + // resulting Buffer is consumed locally before return. let static_slice: &'static mut [f32] = unsafe { std::mem::transmute::<&mut [f32], &'static mut [f32]>(slice) }; @@ -795,18 +879,20 @@ impl Wrapper

{ inputs: &mut [], outputs: &mut [], }; + let sr = this.sample_rate(); let mut process_ctx = AuProcessContext::

{ sink: this.sink.clone(), - transport: Transport::new(this.sample_rate as f32), + transport: Transport::new(sr as f32), _marker: PhantomData, }; - let _status = this.plugin.process(&mut buffer, &mut aux, &mut process_ctx); + // 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 buffer, &mut aux, &mut process_ctx); - // Pull any latency change the plugin requested during process. let latency = this.sink.latency_samples.load(Ordering::Relaxed); - if latency > 0 && this.sample_rate > 0.0 { - this.latency_seconds = latency as f64 / this.sample_rate; + if latency > 0 && sr > 0.0 { + this.set_latency_seconds(latency as f64 / sr); } au::noErr @@ -833,12 +919,10 @@ impl Wrapper

{ /// Build the `AudioUnitParameterInfo` blob the host queries for each /// parameter. Populates the legacy 52-byte name buffer; the CFString slot is -/// left null until Phase 4 brings in the CoreFoundation bridge. +/// left null until AUD-339 (Phase 4 CoreFoundation bridge). fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { let mut info: au::AudioUnitParameterInfo = unsafe { std::mem::zeroed() }; - // Legacy 52-byte name buffer. AU tools and older hosts read this when the - // modern CFString slot is absent. let name_str = unsafe { entry.ptr.name() }; let max = info.name.len() - 1; let bytes = name_str.as_bytes(); @@ -856,9 +940,6 @@ fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { info.maxValue = max_plain; info.defaultValue = default_plain; - // Boolean parameters always have step_count == Some(1); enums Some(n>=2). - // Both map to AU's `Indexed`. Floats have None — we then guess from the - // unit string. let unit = match unsafe { entry.ptr.step_count() } { Some(1) => au::kAudioUnitParameterUnit_Boolean, Some(_) => au::kAudioUnitParameterUnit_Indexed, @@ -879,8 +960,6 @@ fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { info } -/// Heuristic mapping from nih-plug's `Param::unit()` display string to an -/// AU unit ID. Hosts use this to format the value in their generic UI. fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { let lower = unit.to_ascii_lowercase(); if lower.contains("db") || lower.contains("decibel") { @@ -896,12 +975,8 @@ fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { } } -unsafe impl Send for Wrapper

{} - pub use Wrapper as AuWrapper; -/// Write zeros into every non-null buffer in `bl`. Used as a safe fallback -/// when render is called before initialization has succeeded. unsafe fn zero_buffer_list(bl: *mut au::AudioBufferList, n_frames: au::UInt32) { if bl.is_null() { return; From ad26004c40200f2104cb1702134441be030c239e Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 18:08:46 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat(au):=20AUD-331/333/334=20=E2=80=94?= =?UTF-8?q?=20render()=20alloc-free=20+=20BufferList=20align=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 P0를 함께 처리. render() 핫패스가 한 PR에 모이고 같은 RenderState 구조 위에서 풀려서 분리하면 동일 코드를 세 번 만져야 했음. # AUD-331 — render() audio-thread alloc 제거 - `vec![0u8; bl_bytes]`, `Vec::with_capacity(n_buffers)`, `eprintln!` 디버그 블록 모두 제거. - 새 `RenderState` 구조에 `input_scratch`, `bl_storage`, persistent `Buffer<'static>`을 묶고 `Initialize`에서 한 번 provision. - 채널 슬롯 벡터는 `provision()`에서 `reserve_exact + push(&mut [])`로 미리 마련, render는 in-place 재기록만. # AUD-334 — AudioBufferList 가변 길이 alignment 수정 - 이전: `header_size = sizeof(UInt32) = 4`로 가정 → AudioBuffer align(8)과 어긋나 첫 buffer가 패딩 영역에 쓰일 위험. - 수정: `mem::offset_of!(AudioBufferList, mBuffers)` 사용. 컴파일러가 ABI 패딩까지 정확히 계산. - bl_storage를 `Vec`로 바꿔 8-byte 정렬 보장. - `bl_byte_size(n)` 헬퍼로 정확한 크기 계산. # AUD-333 — `&'static mut [f32]` transmute 캡슐화 - 자유롭게 떠다니던 `Vec<&'static mut [f32]>` 임시 컬렉션 제거. - 슬롯은 `RenderState.buffer`(persistent `Buffer<'static>`) 내부에만 존재. - render 종료 직전 모든 슬롯을 `&mut []`로 명시적으로 비움 → `'static` lifetime이 host data보다 오래 살 수 없음. - nih-plug 공식 `BufferManager::create_buffers`와 동일한 안전 모델 (Buffer API 자체가 lifetime parameter를 가지므로 호출처에서 short-cut transmute가 1회 불가피 — 작성자도 buffer.rs의 TODO에서 인정). DoD: - cargo build --features au,assert_process_allocs ✅ - render() 핫패스 alloc/eprintln grep 0건 - transmute는 `BufferManager`와 동일 패턴으로 캡슐화 (주석에 명시) --- src/wrapper/au/wrapper.rs | 256 ++++++++++++++++++++++++++------------ 1 file changed, 178 insertions(+), 78 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 6f69a8bb1..807111894 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -34,6 +34,7 @@ use std::cell::UnsafeCell; use std::ffi::c_void; use std::marker::PhantomData; +use std::mem; use std::num::NonZeroU32; use std::ptr; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; @@ -112,14 +113,93 @@ pub struct Wrapper { /// the mutex at the start of render and releases immediately. input_callback: Mutex>, - /// Per-channel scratch buffers for the input pull. Sized in `Initialize` - /// to `[max_frames_per_slice]` so the render path is allocation-free. - /// One `Vec` per channel; we hand the host a synthesised - /// `AudioBufferList` whose `mData` pointers point into these vectors. + /// Audio-thread-owned render state: input scratch, BufferList scratch, + /// and the persistent `Buffer<'static>` whose channel slot vector is + /// pre-sized in `Initialize`. /// /// `UnsafeCell` because only `render()` touches it after `Initialize`, /// and AU does not re-enter render. - input_scratch: UnsafeCell>>, + render_state: UnsafeCell, +} + +/// 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 { + /// 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>, + + /// Backing storage for the synthesised `AudioBufferList` we hand to + /// the host's input callback. Sized once in `Initialize` to fit the + /// header + N `AudioBuffer` entries with correct alignment. + /// Stored as `Vec` to guarantee 8-byte alignment, which matches + /// `AudioBuffer`'s layout (one `u32` + one `u32` + one `*mut c_void`). + bl_storage: Vec, + + /// Persistent `Buffer` whose `output_slices` vector has its capacity + /// pre-grown to the channel count. Each render call rewrites the + /// existing slots in place — no `Vec::push`/`with_capacity`. + /// + /// The `'static` lifetime parameter is a placeholder; the slices we + /// stash inside live only as long as the surrounding `render()` call, + /// and we always clear them before returning. + buffer: Buffer<'static>, +} + +impl RenderState { + fn new() -> Self { + Self { + input_scratch: Vec::new(), + bl_storage: Vec::new(), + buffer: Buffer::default(), + } + } + + /// Provision storage for `n_channels` channels and `max_frames` + /// frames per slice. Called from `Initialize` only. + fn provision(&mut self, n_channels: usize, max_frames: usize) { + self.input_scratch.clear(); + self.input_scratch.reserve_exact(n_channels); + for _ in 0..n_channels { + self.input_scratch.push(vec![0.0_f32; max_frames]); + } + + // Layout the BufferList: header + N AudioBuffers, with the N-array + // starting at the natural offset of `mBuffers` (8-byte aligned). + let bl_bytes = bl_byte_size(n_channels); + // Round up to u64 count. + let words = bl_bytes.div_ceil(mem::size_of::()); + self.bl_storage.clear(); + self.bl_storage.resize(words, 0); + + // Pre-size the channel slot vector so render only rewrites slots. + // SAFETY: empty slices carry no provenance; rewriting them in + // render with valid host pointers is the same pattern used by + // BufferManager::for_audio_io_layout. + unsafe { + self.buffer.set_slices(0, |slices| { + slices.clear(); + slices.reserve_exact(n_channels); + for _ in 0..n_channels { + slices.push(&mut []); + } + }); + } + } +} + +/// Compute the exact byte size of an `AudioBufferList` with `n` buffers, +/// honoring the offset of `mBuffers` (which the C compiler inserts padding +/// for so the array is correctly aligned). +#[inline] +fn bl_byte_size(n: usize) -> usize { + // The platform's `AudioBufferList` declares `mBuffers: [AudioBuffer; 1]`. + // Its `offset_of(mBuffers)` is the correct start of the array on this + // ABI — typically 8 on x86_64/arm64 (4 bytes for `mNumberBuffers` + 4 + // bytes of padding before the 8-byte-aligned `AudioBuffer`). + let header_offset = mem::offset_of!(au::AudioBufferList, mBuffers); + header_offset + n * mem::size_of::() } struct ParamEntry { @@ -182,7 +262,7 @@ impl Wrapper

{ _params_arc: params_arc, sink: ContextSink::new(), input_callback: Mutex::new(None), - input_scratch: UnsafeCell::new(Vec::new()), + render_state: UnsafeCell::new(RenderState::new()), }); Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface } @@ -239,12 +319,13 @@ impl Wrapper

{ unsafe { &mut **self.plugin.get() } } - /// SAFETY: same as `plugin_mut` — only call from `render()`. Returns the - /// scratch vector for in-place mutation by the audio thread. + /// SAFETY: same as `plugin_mut` — only call from `render()` or from + /// `initialize()` (host serialises against render). Returns the + /// `RenderState` for in-place mutation by the audio thread. #[inline] #[allow(clippy::mut_from_ref)] - unsafe fn input_scratch_mut(&self) -> &mut Vec> { - unsafe { &mut *self.input_scratch.get() } + unsafe fn render_state_mut(&self) -> &mut RenderState { + unsafe { &mut *self.render_state.get() } } // ───────────────────────────────────────────────────────────────────── @@ -346,16 +427,10 @@ impl Wrapper

{ } plugin.reset(); - // Allocate input scratch space: one Vec per channel, each - // sized to max_frames_per_slice so the render path is allocation - // free. - // SAFETY: same — render is serialised vs. Initialize. - let scratch = unsafe { this.input_scratch_mut() }; - scratch.clear(); - scratch.reserve(n_ch as usize); - for _ in 0..n_ch as usize { - scratch.push(vec![0.0_f32; max_frames as usize]); - } + // 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); let latency = this.sink.latency_samples.load(Ordering::Relaxed); if latency > 0 && sr > 0.0 { @@ -738,12 +813,16 @@ impl Wrapper

{ au::noErr } - /// Phase 3 render. Convert the host's `AudioBufferList` into the + /// Render hot path. Convert the host's `AudioBufferList` into the /// nih-plug `Buffer` shape and dispatch to `Plugin::process`. /// - /// Concurrency: this runs on the audio thread. We only touch the wrapper - /// through `&Self` plus controlled `UnsafeCell` borrows for the things - /// the audio thread owns (`plugin`, `input_scratch`). + /// Concurrency: runs on the audio thread. The wrapper is borrowed as + /// `&Self`; mutable access to the plugin and to the render scratch goes + /// through `UnsafeCell` borrows that are unique by AU's host contract + /// (no render re-entry, no concurrent Initialize/Uninitialize/Reset). + /// + /// Allocation: this function performs no heap allocations on the hot + /// path. All scratch storage is provisioned in `Initialize`. unsafe extern "C" fn render( self_ptr: *mut c_void, _io_action_flags: *mut au::AudioUnitRenderActionFlags, @@ -763,52 +842,54 @@ impl Wrapper

{ return au::noErr; } - let n_frames_us = in_number_frames as usize; + let n_frames = in_number_frames as usize; // Snapshot the input callback under the mutex (cheap struct copy). - // Held only for the duration of the load so main-thread updates - // never block render for long. Note: the lock itself can technically - // contend with main thread; this is addressed in AUD-337 via a - // lock-free swap. For now, the only writer is SetRenderCallback - // which is rare. - let callback_snapshot = this - .input_callback - .lock() - .ok() - .and_then(|g| *g); - - // ── 1) Pull input from the host (callback path). ───────────────── - // SAFETY: render is not re-entered, so the audio thread is the sole - // owner of input_scratch right now. - let input_scratch = unsafe { this.input_scratch_mut() }; + // Released immediately so main-thread updates don't block render + // for long. Lock-free swap is tracked in AUD-337. + let callback_snapshot = this.input_callback.lock().ok().and_then(|g| *g); + + // SAFETY: render is not re-entered; we are the sole owner of + // RenderState for the duration of this call. + let rs = unsafe { this.render_state_mut() }; + + // ── 1) Pull input via host render callback (if registered). ────── let mut pulled_from_callback = false; if let Some(cb) = callback_snapshot { - let n_ch = input_scratch.len(); - if n_ch > 0 && input_scratch[0].len() >= n_frames_us { - let header_size = std::mem::size_of::(); - let buf_size = std::mem::size_of::(); - let bl_bytes = header_size + buf_size * n_ch; - let mut bl_storage: Vec = vec![0u8; bl_bytes]; - let bl_ptr = bl_storage.as_mut_ptr() as *mut au::AudioBufferList; + let n_ch = rs.input_scratch.len(); + // Verify our pre-allocated BufferList scratch is big enough. + // Should always hold since `provision` sized it for n_ch and + // n_ch is fixed between Initialize calls. + if n_ch > 0 + && rs.input_scratch[0].len() >= n_frames + && rs.bl_storage.len() * mem::size_of::() >= bl_byte_size(n_ch) + { + let bl_ptr = rs.bl_storage.as_mut_ptr() as *mut au::AudioBufferList; unsafe { (*bl_ptr).mNumberBuffers = n_ch as au::UInt32; } + // 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 buffers_ptr = unsafe { - bl_storage.as_mut_ptr().add(header_size) as *mut au::AudioBuffer + (rs.bl_storage.as_mut_ptr() as *mut u8).add(header_offset) + as *mut au::AudioBuffer }; for ch in 0..n_ch { - let scratch_ptr = input_scratch[ch].as_mut_ptr(); + let scratch_ptr = rs.input_scratch[ch].as_mut_ptr(); unsafe { *buffers_ptr.add(ch) = au::AudioBuffer { mNumberChannels: 1, - mDataByteSize: (n_frames_us * std::mem::size_of::()) + mDataByteSize: (n_frames * mem::size_of::()) as au::UInt32, mData: scratch_ptr as *mut c_void, }; } } - let ts: au::AudioTimeStamp = unsafe { std::mem::zeroed() }; + let ts: au::AudioTimeStamp = unsafe { mem::zeroed() }; let mut flags: au::AudioUnitRenderActionFlags = 0; let proc = cb.inputProc.unwrap(); let status = unsafe { @@ -827,9 +908,7 @@ impl Wrapper

{ } } - let n_frames = n_frames_us; - - // ── 2) Build process buffer slices over io_data. ───────────────── + // ── 2) Wire host io_data slices into the persistent Buffer. ────── let bl = unsafe { &mut *io_data }; let n_buffers = bl.mNumberBuffers as usize; let buffers_ptr = bl.mBuffers.as_mut_ptr(); @@ -837,7 +916,7 @@ impl Wrapper

{ // If we pulled via callback, copy scratch → io_data so process() // sees the input audio. if pulled_from_callback { - for i in 0..n_buffers.min(input_scratch.len()) { + for i in 0..n_buffers.min(rs.input_scratch.len()) { let buf = unsafe { &mut *buffers_ptr.add(i) }; if buf.mData.is_null() { continue; @@ -845,33 +924,40 @@ impl Wrapper

{ let dst = unsafe { std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) }; - let src = &input_scratch[i][..n_frames]; + let src = &rs.input_scratch[i][..n_frames]; dst.copy_from_slice(src); } } - let mut channels: Vec<&'static mut [f32]> = Vec::with_capacity(n_buffers); - for i in 0..n_buffers { - let buf = unsafe { &mut *buffers_ptr.add(i) }; - if buf.mData.is_null() { - continue; - } - let slice = unsafe { - std::slice::from_raw_parts_mut(buf.mData as *mut f32, n_frames) - }; - // SAFETY: erasing the lifetime is unsound in general; tracked in - // AUD-333. The slice never outlives this render() call and the - // resulting Buffer is consumed locally before return. - let static_slice: &'static mut [f32] = unsafe { - std::mem::transmute::<&mut [f32], &'static mut [f32]>(slice) - }; - channels.push(static_slice); - } - - let mut buffer = Buffer::default(); + // Rewrite the persistent Buffer's slot vector. The slot count was + // pre-grown in `provision()`, so this is purely in-place writes. + // SAFETY: each slice points to host-owned memory that remains valid + // for the duration of this render() call. The slices are cleared + // back to `&mut []` at the end of this function so the `'static` + // lifetime in the slot type can never outlive the host data. unsafe { - buffer.set_slices(n_frames, |dst| { - *dst = channels; + rs.buffer.set_slices(n_frames, |slots| { + let n = slots.len().min(n_buffers); + for i in 0..n { + let buf = &mut *buffers_ptr.add(i); + if buf.mData.is_null() { + slots[i] = &mut []; + continue; + } + 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 + // pattern in `BufferManager::create_buffers`. + slots[i] = mem::transmute::<&mut [f32], &'static mut [f32]>(raw); + } + // Defensively null any extra slots. + for slot in &mut slots[n..] { + *slot = &mut []; + } }); } @@ -888,7 +974,21 @@ impl Wrapper

{ // 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 buffer, &mut aux, &mut process_ctx); + let _status = unsafe { this.plugin_mut() }.process( + &mut rs.buffer, + &mut aux, + &mut process_ctx, + ); + + // 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 []; + } + }); + } let latency = this.sink.latency_samples.load(Ordering::Relaxed); if latency > 0 && sr > 0.0 { From f4c30e232f67c999577faa67b6614b007ce34582 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 18:12:33 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat(au):=20AUD-335=20=E2=80=94=20StreamF?= =?UTF-8?q?ormat=20/=20Initialize=20=EB=9D=BC=EC=9D=B4=ED=94=84=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=81=B4=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetProperty(StreamFormat / SampleRate / MaximumFramesPerSlice)는 Initialized 상태에서 kAudioUnitErr_Initialized 반환. - StreamFormat: format/flags/bitsPerChannel 검증 (PCM, float, non-interleaved, 32-bit). 미일치 시 kAudioUnitErr_FormatNotSupported. - mChannelsPerFrame == 0 → kAudioUnitErr_InvalidPropertyValue. - 채널 수가 P::AUDIO_IO_LAYOUTS 어느 항목과도 매칭되지 않으면 reject. - SampleRate: sr <= 0 또는 NaN/Inf reject. - MaximumFramesPerSlice: 0 reject. - GetProperty(SupportedNumChannels): 첫 declared layout 기반으로 응답 (이전엔 (-1, -1) wildcard). 새 헬퍼 `layout_supports::

(req_ch)`: P::AUDIO_IO_LAYOUTS 순회하며 main_input/main_output 둘 다 매칭되는 항목 존재 여부 확인. DoD: cargo build --features au,assert_process_allocs ✅. auval 검증은 실호스트 환경 필요 — follow-up. --- src/wrapper/au/wrapper.rs | 83 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 807111894..51d0ce198 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -674,11 +674,31 @@ impl Wrapper

{ if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } - unsafe { - *(out_data as *mut au::AUChannelInfo) = au::AUChannelInfo { + // 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, + } + } + None => au::AUChannelInfo { inChannels: -1, outChannels: -1, - }; + }, + }; + unsafe { + *(out_data as *mut au::AUChannelInfo) = info; *io_data_size = std::mem::size_of::() as u32; } au::noErr @@ -723,7 +743,13 @@ impl Wrapper

{ if (in_data_size as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } + if this.is_initialized() { + return au::kAudioUnitErr_Initialized; + } let sr = unsafe { *(in_data as *const au::Float64) }; + if !(sr > 0.0 && sr.is_finite()) { + return au::kAudioUnitErr_InvalidPropertyValue; + } this.set_sample_rate(sr); au::noErr } @@ -733,17 +759,50 @@ impl Wrapper

{ { return au::kAudioUnitErr_InvalidPropertyValue; } + // AU spec: StreamFormat may not change while initialized. + if this.is_initialized() { + return au::kAudioUnitErr_Initialized; + } let asbd = unsafe { &*(in_data as *const au::AudioStreamBasicDescription) }; + + // Reject malformed channel count. + if asbd.mChannelsPerFrame == 0 { + 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 + || (asbd.mFormatFlags & au::kAudioFormatFlagIsFloat) == 0 + || (asbd.mFormatFlags & au::kAudioFormatFlagIsNonInterleaved) == 0 + { + return au::kAudioUnitErr_FormatNotSupported; + } + 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; + } + this.set_sample_rate(asbd.mSampleRate); - this.n_channels - .store(asbd.mChannelsPerFrame, Ordering::Release); + this.n_channels.store(req_ch, Ordering::Release); au::noErr } au::kAudioUnitProperty_MaximumFramesPerSlice => { if (in_data_size as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } + if this.is_initialized() { + return au::kAudioUnitErr_Initialized; + } let v = unsafe { *(in_data as *const au::UInt32) }; + if v == 0 { + return au::kAudioUnitErr_InvalidPropertyValue; + } this.max_frames_per_slice.store(v, Ordering::Release); au::noErr } @@ -1060,6 +1119,20 @@ fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { info } +/// 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) + }) +} + fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { let lower = unit.to_ascii_lowercase(); if lower.contains("db") || lower.contains("decibel") { From b8f3a743ad01f2dbc2bbe2ee65205937355779fd Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 18:14:21 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat(au):=20AUD-336=20=E2=80=94=20BypassE?= =?UTF-8?q?ffect=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrapper에 bypass: AtomicBool, bypass_param_idx: Option 추가. - new()에서 P::params()의 ParamFlags::BYPASS 플래그 가진 파라미터 감지. - get_property(BypassEffect): atomic load 반환 (이전 항상 0). - set_property(BypassEffect): * UInt32 검증 후 atomic store. * BYPASS-flagged 파라미터가 있으면 그것도 0/1로 동기화 → 플러그인이 자체 param으로 bypass 상태를 관찰할 수 있음. - render(): bypass=true면 Plugin::process 호출 건너뜀. 입력이 이미 io_data에 있으므로(in-place 또는 callback path 카피 후) pass-through는 암묵적. DoD: cargo build --features au,assert_process_allocs ✅. DAW에서 토글 검증은 실호스트 환경 필요 → follow-up. --- src/wrapper/au/wrapper.rs | 65 +++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 51d0ce198..973f564e5 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -45,7 +45,7 @@ use au_sys as au; use crate::buffer::Buffer; use crate::context::process::Transport; use crate::params::internals::ParamPtr; -use crate::params::Params; +use crate::params::{ParamFlags, Params}; use crate::plugin::au::AuPlugin; use crate::prelude::{AudioIOLayout, AuxiliaryBuffers, BufferConfig, ProcessMode}; @@ -81,6 +81,16 @@ pub struct Wrapper { /// `Uninitialize` / first construction. Render is a no-op when false. initialized: AtomicBool, + /// Host-controlled bypass (`kAudioUnitProperty_BypassEffect`). When set, + /// `render()` skips `Plugin::process()` and just passes input → output. + /// If the plugin declares a `ParamFlags::BYPASS` parameter we keep it in + /// sync so plugins that observe bypass state through their own param see + /// the toggle too. + bypass: AtomicBool, + + /// Index of the BYPASS-flagged param in `params_by_id`, if any. + bypass_param_idx: Option, + /// The plugin instance. Kept for the entire lifetime of the wrapper so /// the `ParamPtr` raw pointers in `params_by_id` remain valid. /// @@ -244,6 +254,12 @@ impl Wrapper

{ .map(|(id_str, ptr, _group)| ParamEntry { id_str, ptr }) .collect(); + // Find the BYPASS-flagged param, if the plugin declares one. + let bypass_param_idx = params_by_id.iter().position(|e| { + // SAFETY: ParamPtr accessors are documented thread-safe. + unsafe { e.ptr.flags() }.contains(ParamFlags::BYPASS) + }); + let boxed = Box::new(Wrapper::

{ vtable: au::AudioComponentPlugInInterface { Open: Self::open, @@ -257,6 +273,8 @@ impl Wrapper

{ n_channels: AtomicU32::new(2), latency_seconds_bits: AtomicU64::new(pack_f64(0.0)), initialized: AtomicBool::new(false), + bypass: AtomicBool::new(false), + bypass_param_idx, plugin: UnsafeCell::new(plugin), params_by_id, _params_arc: params_arc, @@ -704,8 +722,9 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_BypassEffect if scope == au::kAudioUnitScope_Global => { + let on = if this.bypass.load(Ordering::Acquire) { 1 } else { 0 }; unsafe { - *(out_data as *mut au::UInt32) = 0; + *(out_data as *mut au::UInt32) = on; *io_data_size = std::mem::size_of::() as u32; } au::noErr @@ -806,7 +825,27 @@ impl Wrapper

{ this.max_frames_per_slice.store(v, Ordering::Release); au::noErr } - au::kAudioUnitProperty_BypassEffect => au::noErr, + au::kAudioUnitProperty_BypassEffect => { + if (in_data_size as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let v = unsafe { *(in_data as *const au::UInt32) }; + let on = v != 0; + this.bypass.store(on, Ordering::Release); + // Mirror into the plugin's BYPASS-flagged param if present, + // so plugins can observe the toggle through their own params. + if let Some(idx) = this.bypass_param_idx { + if let Some(entry) = this.params_by_id.get(idx) { + // Boolean params use 0.0 / 1.0 normalised. set_normalized_value + // is documented thread-safe. + let n = if on { 1.0 } else { 0.0 }; + unsafe { + let _ = entry.ptr.set_normalized_value(n); + } + } + } + au::noErr + } au::kAudioUnitProperty_SetRenderCallback => { if (in_data_size as usize) < std::mem::size_of::() @@ -1031,13 +1070,19 @@ impl Wrapper

{ _marker: PhantomData, }; - // 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, - ); + // 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) { + // 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, + ); + } // Clear the slot slices so the `'static` lifetime can never escape // this render call via a stale `&mut [f32]`. From 74a3b959c5d04ea0ef4d6046f44185e9d51799ff Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 18:17:35 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat(au):=20AUD-337=20=E2=80=94=20Propert?= =?UTF-8?q?y=20Listener=20=ED=86=B5=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrapper에 listeners: Mutex>, pending_notifications: AtomicU32 추가. - add/remove_property_listener_with_user_data: stub → 실제 등록/해제. remove는 (property_id, proc 주소, user_data) 3-tuple 매칭. - mark_pending(mask)으로 변경 비트 OR. NOTIFY_LATENCY/STREAM_FORMAT/BYPASS_EFFECT. - drain_notifications()는 main-thread 전용. swap으로 비트 회수, listeners 스냅샷(락 짧게), 매칭되는 listener의 proc(user_data, instance, id, scope, 0) 호출. 호스트 콜백을 락 holding 상태에서 호출하지 않음. - 변경 트리거 지점: * set_property(StreamFormat): NOTIFY_STREAM_FORMAT * set_property(BypassEffect): 값이 실제 바뀐 경우만 NOTIFY_BYPASS_EFFECT * initialize 끝 + render 끝: latency 변동 시 NOTIFY_LATENCY - Drain 진입점: get_property/set_property/initialize 시작/끝. audio thread의 render는 mark만 하고 drain하지 않음 → 다음 main-thread 진입에서 자동 처리. - Listener Send/Sync 명시. proc은 fn ptr, user_data는 host-owned opaque. DoD: cargo build --features au,assert_process_allocs ✅. PDC/bypass 외부 변경 통지 동작 확인은 실호스트 환경 필요. --- src/wrapper/au/wrapper.rs | 148 +++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 11 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 973f564e5..a3bb0a0b7 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -91,6 +91,15 @@ pub struct Wrapper { /// Index of the BYPASS-flagged param in `params_by_id`, if any. bypass_param_idx: Option, + /// Property listeners registered by the host. Notifications fire on the + /// main thread; audio-thread changes (latency, etc.) only set bits in + /// `pending_notifications` and the next main-thread entry drains them. + listeners: Mutex>, + + /// Bitset of property changes pending main-thread notification. + /// See `NOTIFY_*` constants. + pending_notifications: AtomicU32, + /// The plugin instance. Kept for the entire lifetime of the wrapper so /// the `ParamPtr` raw pointers in `params_by_id` remain valid. /// @@ -219,6 +228,26 @@ struct ParamEntry { ptr: ParamPtr, } +/// One registered host property listener. The `proc` is called whenever the +/// matching property changes. The host owns `user_data`; we only forward it. +#[derive(Copy, Clone)] +struct Listener { + property_id: au::AudioUnitPropertyID, + proc: au::AudioUnitPropertyListenerProc, + user_data: *mut c_void, +} + +// SAFETY: `proc` is a function pointer (Send/Sync trivially), `user_data` is +// host-owned opaque storage we never dereference, only forward. +unsafe impl Send for Listener {} +unsafe impl Sync for Listener {} + +// Bits in `pending_notifications`. Each bit maps to a property whose listeners +// must be called from the main thread. +const NOTIFY_LATENCY: u32 = 1 << 0; +const NOTIFY_STREAM_FORMAT: u32 = 1 << 1; +const NOTIFY_BYPASS_EFFECT: u32 = 1 << 2; + /// `Send` + `Sync` justification: /// /// - All publicly mutable fields are atomic or behind a `Mutex`. @@ -280,6 +309,8 @@ impl Wrapper

{ _params_arc: params_arc, sink: ContextSink::new(), input_callback: Mutex::new(None), + listeners: Mutex::new(Vec::new()), + pending_notifications: AtomicU32::new(0), render_state: UnsafeCell::new(RenderState::new()), }); Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface @@ -327,6 +358,54 @@ impl Wrapper

{ self.initialized.load(Ordering::Acquire) } + /// Mark `mask` of property bits as needing notification. Cheap atomic OR; + /// callable from any thread. + #[inline] + fn mark_pending(&self, mask: u32) { + self.pending_notifications.fetch_or(mask, Ordering::Release); + } + + /// Drain any pending notifications and call registered listeners. + /// **Must be called from the main thread only** — host listener procs + /// generally are not audio-safe. Called from main-thread entry points + /// (`get_property`, `set_property`, `initialize`, etc.). + fn drain_notifications(&self) { + let pending = self.pending_notifications.swap(0, Ordering::AcqRel); + if pending == 0 { + return; + } + let instance = self.instance.load(Ordering::Acquire) as usize as au::AudioUnit; + if instance.is_null() { + return; + } + // Snapshot listeners so the lock isn't held across host callbacks. + let listeners = match self.listeners.lock() { + Ok(g) => g.clone(), + Err(_) => return, + }; + let fire = |id: au::AudioUnitPropertyID, scope: au::AudioUnitScope| { + for l in &listeners { + if l.property_id == id { + // SAFETY: host owns user_data; instance is the same + // AudioUnit handle the host registered against. + unsafe { + (l.proc)(l.user_data, instance, id, scope, 0); + } + } + } + }; + if pending & NOTIFY_LATENCY != 0 { + 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); + } + if pending & NOTIFY_BYPASS_EFFECT != 0 { + fire(au::kAudioUnitProperty_BypassEffect, au::kAudioUnitScope_Global); + } + } + /// SAFETY: caller must guarantee no other reference (mut or shared) to /// `*self.plugin.get()` is alive. AU's contract gives this for the /// `Initialize` / `Uninitialize` / `Reset` / `Render` paths because the @@ -452,10 +531,16 @@ impl Wrapper

{ let latency = this.sink.latency_samples.load(Ordering::Relaxed); if latency > 0 && sr > 0.0 { - this.set_latency_seconds(latency as f64 / sr); + let new_l = latency as f64 / sr; + let prev_l = this.latency_seconds(); + this.set_latency_seconds(new_l); + if (prev_l - new_l).abs() > f64::EPSILON { + this.mark_pending(NOTIFY_LATENCY); + } } this.initialized.store(true, Ordering::Release); + this.drain_notifications(); au::noErr } @@ -576,6 +661,8 @@ impl Wrapper

{ io_data_size: *mut au::UInt32, ) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; + // Main-thread entry: drain any audio-thread-marked notifications. + this.drain_notifications(); if out_data.is_null() || io_data_size.is_null() { return au::kAudioUnitErr_InvalidParameter; @@ -756,7 +843,18 @@ impl Wrapper

{ in_data_size: au::UInt32, ) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; + let status = Self::set_property_impl(this, id, in_data, in_data_size); + // Drain after the change so listeners see the new value. + this.drain_notifications(); + status + } + fn set_property_impl( + this: &Self, + id: au::AudioUnitPropertyID, + in_data: *const c_void, + in_data_size: au::UInt32, + ) -> au::OSStatus { match id { au::kAudioUnitProperty_SampleRate => { if (in_data_size as usize) < std::mem::size_of::() { @@ -809,6 +907,7 @@ impl Wrapper

{ this.set_sample_rate(asbd.mSampleRate); this.n_channels.store(req_ch, Ordering::Release); + this.mark_pending(NOTIFY_STREAM_FORMAT); au::noErr } au::kAudioUnitProperty_MaximumFramesPerSlice => { @@ -831,7 +930,10 @@ impl Wrapper

{ } let v = unsafe { *(in_data as *const au::UInt32) }; let on = v != 0; - this.bypass.store(on, Ordering::Release); + let prev = this.bypass.swap(on, Ordering::AcqRel); + if prev != on { + this.mark_pending(NOTIFY_BYPASS_EFFECT); + } // Mirror into the plugin's BYPASS-flagged param if present, // so plugins can observe the toggle through their own params. if let Some(idx) = this.bypass_param_idx { @@ -1096,27 +1198,51 @@ impl Wrapper

{ let latency = this.sink.latency_samples.load(Ordering::Relaxed); if latency > 0 && sr > 0.0 { - this.set_latency_seconds(latency as f64 / sr); + let new_l = latency as f64 / sr; + let prev_l = this.latency_seconds(); + this.set_latency_seconds(new_l); + if (prev_l - new_l).abs() > f64::EPSILON { + // We're on the audio thread; just mark pending. The next + // main-thread property/lifecycle call will drain it. + this.mark_pending(NOTIFY_LATENCY); + } } au::noErr } unsafe extern "C" fn add_property_listener( - _self_ptr: *mut c_void, - _id: au::AudioUnitPropertyID, - _proc: au::AudioUnitPropertyListenerProc, - _user_data: *mut c_void, + self_ptr: *mut c_void, + id: au::AudioUnitPropertyID, + proc: au::AudioUnitPropertyListenerProc, + user_data: *mut c_void, ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + if let Ok(mut guard) = this.listeners.lock() { + guard.push(Listener { + property_id: id, + proc, + user_data, + }); + } au::noErr } unsafe extern "C" fn remove_property_listener_with_user_data( - _self_ptr: *mut c_void, - _id: au::AudioUnitPropertyID, - _proc: au::AudioUnitPropertyListenerProc, - _user_data: *mut c_void, + self_ptr: *mut c_void, + id: au::AudioUnitPropertyID, + proc: au::AudioUnitPropertyListenerProc, + user_data: *mut c_void, ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + 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) + }); + } au::noErr } } From 6e28b0743bf084c0da59aefc0a85c96b241a0115 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 18:19:19 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat(au):=20AUD-338=20=E2=80=94=20set=5Fp?= =?UTF-8?q?arameter=20audio-thread=20=EC=95=88=EC=A0=84=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20+=20bypass=20=EC=96=91=EB=B0=A9=ED=96=A5?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증: - set_parameter는 ParamPtr::set_normalized_value → 구체 Param의 set_plain_value → AtomicF32::swap + 몇 개의 store만 거침. lock-free, alloc-free 확인 (src/params/{float,integer,boolean,enums}.rs 코드 인용). - 유일한 escape hatch는 사용자가 등록한 value_changed: Arc; 플러그인 작성자 책임으로 명시 (nih-plug 기본 None). buffer_offset_in_frames 정책: - nih-plug Plugin::process는 단일 contiguous buffer로 받고 sample-accurate parameter dispatch를 지원하지 않음. offset>0이어도 즉시 적용. - 워스트케이스 grain 오차는 max_frames_per_slice 샘플 (보통 ≤1024 @ 48k → ≤21ms). 알려진 한계로 코드 주석에 명시. 추가: - BYPASS 플래그 가진 파라미터를 set_parameter로 변경하면 wrapper의 bypass: AtomicBool도 동기화 + listener 통지. 호스트가 bypass를 property 또는 parameter 어느 경로로 토글하든 일관 동작. DoD: cargo build --features au,assert_process_allocs ✅. 호출 경로 alloc-free 증명은 docstring에 명시. --- src/wrapper/au/wrapper.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index a3bb0a0b7..96001bc9e 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -993,6 +993,31 @@ impl Wrapper

{ /// Set a parameter from the host. AU sends the plain value; convert back /// to the [0, 1] normalised range nih-plug expects. + /// + /// # Audio-thread safety + /// + /// AU may invoke this from the audio thread for sample-accurate + /// automation. The call chain is: + /// `ParamPtr::set_normalized_value` → + /// `::set_normalized_value` (Float/Int/Bool/Enum) → + /// `set_plain_value` → `AtomicF32::swap` + a few `AtomicF32::store`s. + /// All atomic, no allocation, no locking — verified by code inspection + /// of `src/params/{float,integer,boolean,enums}.rs`. The only escape + /// hatch is the user-supplied `value_changed: Arc` callback, + /// which the plugin author owns and must keep audio-safe; nih-plug's + /// default is `None`. + /// + /// # `buffer_offset_in_frames` + /// + /// AU's sample-accurate automation passes a non-zero offset when the + /// parameter change should land mid-buffer. nih-plug's `Plugin::process` + /// API operates on a single contiguous buffer per call and has no + /// per-sample parameter dispatch, so we cannot honour `offset > 0` + /// precisely without splitting the buffer (which we don't do today). + /// Current behaviour: apply the change immediately. The next render + /// call will see it. Worst-case granularity error is `max_frames_per_slice` + /// samples — typically ≤1024 frames at 48 kHz → ≤21 ms. Tracked as a + /// known limitation. unsafe extern "C" fn set_parameter( self_ptr: *mut c_void, id: au::AudioUnitParameterID, @@ -1010,6 +1035,18 @@ impl Wrapper

{ unsafe { let _ = entry.ptr.set_normalized_value(normalized); } + // Mirror BYPASS-flagged param into our atomic if this update was + // routed through the bypass parameter (some hosts expose bypass + // both as a property and as a parameter). + if let Some(bypass_idx) = this.bypass_param_idx { + if id as usize == bypass_idx { + let on = normalized >= 0.5; + let prev = this.bypass.swap(on, Ordering::AcqRel); + if prev != on { + this.mark_pending(NOTIFY_BYPASS_EFFECT); + } + } + } au::noErr } From bfa784ae48f1f64f87093ab7e3d8fb1e2e7bfeab Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 19:31:09 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat(au):=20AUD-339=20=E2=80=94=20CoreFou?= =?UTF-8?q?ndation=20=EB=B8=8C=EB=A6=AC=EC=A7=80(CFString,=20CFDictionary)?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wrapper/au/wrapper.rs | 146 +++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 9 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 96001bc9e..c56aea0fa 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -41,6 +41,11 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use au_sys as au; +use core_foundation::base::{CFType, TCFType}; +use core_foundation::data::CFData; +use core_foundation::dictionary::CFDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; use crate::buffer::Buffer; use crate::context::process::Transport; @@ -48,8 +53,10 @@ use crate::params::internals::ParamPtr; use crate::params::{ParamFlags, Params}; use crate::plugin::au::AuPlugin; use crate::prelude::{AudioIOLayout, AuxiliaryBuffers, BufferConfig, ProcessMode}; +use crate::wrapper::state::{self, PluginState}; use super::context::{AuInitContext, AuProcessContext, ContextSink}; +use super::factory::fourcc; /// One AU plugin instance. Owned by Apple's component manager via the /// `AudioComponentPlugInInterface` pointer returned from the factory. @@ -568,6 +575,91 @@ impl Wrapper

{ au::noErr } + fn get_class_info(&self) -> *mut c_void { + let state = unsafe { + state::serialize_object::

( + self._params_arc.clone(), + self.params_by_id.iter().map(|e| (&e.id_str, e.ptr)), + ) + }; + let json = serde_json::to_string(&state).unwrap_or_default(); + let data = CFData::from_buffer(json.as_bytes()); + + let dict = CFDictionary::from_CFType_pairs(&[ + ( + CFString::from_static_string("version").as_CFType(), + CFNumber::from(0i32).as_CFType(), + ), + ( + CFString::from_static_string("type").as_CFType(), + CFNumber::from(fourcc(P::AU_TYPE) as i32).as_CFType(), + ), + ( + CFString::from_static_string("subtype").as_CFType(), + CFNumber::from(fourcc(P::AU_SUBTYPE) as i32).as_CFType(), + ), + ( + CFString::from_static_string("manufacturer").as_CFType(), + CFNumber::from(fourcc(P::AU_MANUFACTURER) as i32).as_CFType(), + ), + ( + CFString::from_static_string("data").as_CFType(), + data.as_CFType(), + ), + ]); + + let ptr = dict.as_concrete_TypeRef(); + std::mem::forget(dict); + ptr as *mut c_void + } + + fn set_class_info(&self, dict_ptr: *const c_void) -> au::OSStatus { + if dict_ptr.is_null() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + + let dict = unsafe { CFDictionary::::wrap_under_get_rule(dict_ptr as _) }; + + let data_key = CFString::from_static_string("data"); + let data = match dict.find(&data_key) { + Some(d) => unsafe { CFData::wrap_under_get_rule(d.as_CFTypeRef() as _) }, + None => return au::kAudioUnitErr_InvalidPropertyValue, + }; + + let mut state: PluginState = match serde_json::from_slice(data.bytes()) { + Ok(s) => s, + Err(_) => return au::kAudioUnitErr_InvalidPropertyValue, + }; + + let sr = self.sample_rate(); + let buffer_config = if sr > 0.0 { + Some(BufferConfig { + sample_rate: sr as f32, + min_buffer_size: None, + max_buffer_size: self.max_frames_per_slice(), + process_mode: ProcessMode::Realtime, + }) + } else { + None + }; + + unsafe { + state::deserialize_object::

( + &mut state, + self._params_arc.clone(), + |id| { + self.params_by_id + .iter() + .find(|e| e.id_str == id) + .map(|e| e.ptr) + }, + buffer_config.as_ref(), + ); + } + + au::noErr + } + unsafe extern "C" fn get_property_info( self_ptr: *mut c_void, id: au::AudioUnitPropertyID, @@ -648,6 +740,9 @@ impl Wrapper

{ au::kAudioUnitProperty_InPlaceProcessing => { respond(std::mem::size_of::() as u32, true) } + au::kAudioUnitProperty_ClassInfo if scope == au::kAudioUnitScope_Global => { + respond(std::mem::size_of::<*mut c_void>() as u32, true) + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -830,6 +925,17 @@ 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; + } + let dict = this.get_class_info(); + unsafe { + *(out_data as *mut *mut c_void) = dict; + *io_data_size = std::mem::size_of::<*mut c_void>() as u32; + } + au::noErr + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -837,13 +943,13 @@ impl Wrapper

{ unsafe extern "C" fn set_property( self_ptr: *mut c_void, id: au::AudioUnitPropertyID, - _scope: au::AudioUnitScope, + scope: au::AudioUnitScope, _element: au::AudioUnitElement, in_data: *const c_void, in_data_size: au::UInt32, ) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; - let status = Self::set_property_impl(this, id, in_data, in_data_size); + let status = Self::set_property_impl(this, id, scope, in_data, in_data_size); // Drain after the change so listeners see the new value. this.drain_notifications(); status @@ -852,6 +958,7 @@ impl Wrapper

{ fn set_property_impl( this: &Self, id: au::AudioUnitPropertyID, + scope: au::AudioUnitScope, in_data: *const c_void, in_data_size: au::UInt32, ) -> au::OSStatus { @@ -949,9 +1056,7 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_SetRenderCallback => { - if (in_data_size as usize) - < std::mem::size_of::() - { + if (in_data_size as usize) < std::mem::size_of::() { return au::kAudioUnitErr_InvalidPropertyValue; } let cb = unsafe { *(in_data as *const au::AURenderCallbackStruct) }; @@ -962,6 +1067,13 @@ impl Wrapper

{ au::noErr } au::kAudioUnitProperty_InPlaceProcessing => au::noErr, + au::kAudioUnitProperty_ClassInfo if scope == au::kAudioUnitScope_Global => { + if (in_data_size as usize) < std::mem::size_of::<*mut c_void>() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let dict = unsafe { *(in_data as *const *mut c_void) }; + this.set_class_info(dict) + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -1285,8 +1397,8 @@ impl Wrapper

{ } /// Build the `AudioUnitParameterInfo` blob the host queries for each -/// parameter. Populates the legacy 52-byte name buffer; the CFString slot is -/// left null until AUD-339 (Phase 4 CoreFoundation bridge). +/// parameter. Populates the legacy 52-byte name buffer and the modern CFString +/// slot (AUD-339). fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { let mut info: au::AudioUnitParameterInfo = unsafe { std::mem::zeroed() }; @@ -1316,8 +1428,17 @@ fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { } }; info.unit = unit; - info.unitName = std::ptr::null_mut(); - info.cfNameString = std::ptr::null_mut(); + info.unitName = if unit == au::kAudioUnitParameterUnit_Generic { + let unit_str = unsafe { entry.ptr.unit() }; + if !unit_str.is_empty() { + string_to_cfstring(unit_str) + } else { + ptr::null_mut() + } + } else { + ptr::null_mut() + }; + info.cfNameString = string_to_cfstring(name_str); info.clumpID = 0; info.flags = au::kAudioUnitParameterFlag_IsReadable @@ -1327,6 +1448,13 @@ fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { info } +fn string_to_cfstring(s: &str) -> au::CFStringRef { + let cf_string = CFString::new(s); + let ptr = cf_string.as_concrete_TypeRef(); + std::mem::forget(cf_string); + 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. From ca12a23eae20d0824faf7a363b2d77d5b35437de Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 21:32:17 +0900 Subject: [PATCH 12/21] chore: ignore trash/, .gemini/, .claude/, root session transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /trash/ : 직접 rm 대신 휴지통 경유용 워크 디렉토리 - /.gemini/, /.claude/ : 에이전트/툴 메타데이터 (협업과 무관) - /20YY-*.txt : 루트에 떨어지는 세션 트랜스크립트 캡처 --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b83d22266..38762368b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /target/ +/trash/ +/.gemini/ +/.claude/ +# 세션 트랜스크립트/메모 (루트 한정) +/20[0-9][0-9]-*.txt From 024b29c87a26206349aff695b86a97fb54f5aae4 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 21:32:29 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat(au):=20AUD-340=20=E2=80=94=20HostCal?= =?UTF-8?q?lbacks=20=E2=86=92=20Transport=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호스트가 SetProperty(kAudioUnitProperty_HostCallbacks)로 등록한 HostCallbackInfo를 보관하고, render() 시작 시 다음 콜백을 호출해 Transport 구조체를 채운다. - beatAndTempoProc → tempo, pos_beats - musicalTimeLocationProc → time_sig_numerator/denominator, bar_start_pos_beats - transportStateProc → playing, pos_samples 저장은 Mutex>로 두되 audio thread에서는 스냅샷 후 lock을 즉시 해제. get/set property 분기에 HostCallbacks size 응답과 in_data 검증을 추가. --- src/wrapper/au/wrapper.rs | 79 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index c56aea0fa..327065b74 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -123,6 +123,9 @@ pub struct Wrapper { /// index into this vec. Built once in `new()`, then read-only. params_by_id: Vec, + /// Host callbacks for transport, tempo, and time signature. + host_callbacks: Mutex>, + /// Strong reference back to the `Params` object referenced by every /// `ParamPtr` in `params_by_id`. _params_arc: Arc, @@ -313,6 +316,7 @@ impl Wrapper

{ bypass_param_idx, plugin: UnsafeCell::new(plugin), params_by_id, + host_callbacks: Mutex::new(None), _params_arc: params_arc, sink: ContextSink::new(), input_callback: Mutex::new(None), @@ -743,6 +747,9 @@ impl Wrapper

{ au::kAudioUnitProperty_ClassInfo if scope == au::kAudioUnitScope_Global => { respond(std::mem::size_of::<*mut c_void>() as u32, true) } + au::kAudioUnitProperty_HostCallbacks if scope == au::kAudioUnitScope_Global => { + respond(std::mem::size_of::() as u32, true) + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -1074,6 +1081,16 @@ impl Wrapper

{ let dict = unsafe { *(in_data as *const *mut c_void) }; this.set_class_info(dict) } + au::kAudioUnitProperty_HostCallbacks if scope == au::kAudioUnitScope_Global => { + if (in_data_size as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let cb = unsafe { ptr::read(in_data as *const au::HostCallbackInfo) }; + if let Ok(mut guard) = this.host_callbacks.lock() { + *guard = Some(cb); + } + au::noErr + } _ => au::kAudioUnitErr_InvalidProperty, } } @@ -1310,14 +1327,72 @@ impl Wrapper

{ }); } + let sr = this.sample_rate(); + let mut transport = Transport::new(sr as f32); + if let Ok(guard) = this.host_callbacks.lock() { + if let Some(cb) = guard.as_ref() { + 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 + { + transport.pos_beats = Some(beat); + transport.tempo = Some(tempo); + } + } + if let Some(musical_time) = cb.musicalTimeLocationProc { + let mut delta = 0u32; + let mut num = 0.0f32; + let mut den = 0u32; + let mut bar_start = 0.0f64; + if unsafe { + musical_time( + cb.hostUserData, + &mut delta, + &mut num, + &mut den, + &mut bar_start, + ) + } == au::noErr + { + transport.time_sig_numerator = Some(num as i32); + transport.time_sig_denominator = Some(den as i32); + transport.bar_start_pos_beats = Some(bar_start); + } + } + if let Some(state_proc) = cb.transportStateProc { + let mut playing = 0u8; // Boolean is often u8 + let mut changed = 0u8; + let mut sample_pos = 0.0f64; + let mut cycling = 0u8; + let mut cycle_start = 0.0f64; + let mut cycle_end = 0.0f64; + if unsafe { + state_proc( + cb.hostUserData, + &mut playing, + &mut changed, + &mut sample_pos, + &mut cycling, + &mut cycle_start, + &mut cycle_end, + ) + } == au::noErr + { + transport.playing = playing != 0; + transport.pos_samples = Some(sample_pos as i64); + } + } + } + } + let mut aux = AuxiliaryBuffers { inputs: &mut [], outputs: &mut [], }; - let sr = this.sample_rate(); let mut process_ctx = AuProcessContext::

{ sink: this.sink.clone(), - transport: Transport::new(sr as f32), + transport, _marker: PhantomData, }; From eefdf9725f2af6dc1d48a7e906e6bf4d19e92e3a Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 21:51:22 +0900 Subject: [PATCH 14/21] =?UTF-8?q?docs(au):=20string=5Fto=5Fcfstring=20owne?= =?UTF-8?q?rship=20=EC=A3=BC=EC=84=9D=20=E2=80=94=20mem::forget=20?= =?UTF-8?q?=EC=9D=98=EB=8F=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple AU API에서 AudioUnitParameterInfo::cfNameString은 'create' 규약이라 호스트가 CFRelease 책임을 진다. 따라서 이 헬퍼의 mem::forget은 누수가 아니라 의도된 retain count 인계. 미래의 독자가 오인하지 않도록 docstring 추가. --- src/wrapper/au/wrapper.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 327065b74..6832e3a74 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1523,6 +1523,12 @@ fn build_parameter_info(entry: &ParamEntry) -> au::AudioUnitParameterInfo { info } +/// Create a `CFStringRef` whose retain count is +1 and transfer ownership to +/// the caller. Used for `AudioUnitParameterInfo::cfNameString` and `unitName`, +/// which Apple documents as "create" semantics — the host releases the string +/// after reading the parameter info. The `mem::forget` is therefore *not* a +/// leak; dropping the `CFString` here would over-release once the host calls +/// `CFRelease`. fn string_to_cfstring(s: &str) -> au::CFStringRef { let cf_string = CFString::new(s); let ptr = cf_string.as_concrete_TypeRef(); From 9a39a530da3d8fa1978102ce7583a527bc5fbcdb Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 21:52:05 +0900 Subject: [PATCH 15/21] =?UTF-8?q?test(au):=20classify=5Funit=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=205=EC=A2=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AU 모듈은 macOS C API 강결합이라 통합 테스트가 어렵지만, 순수 Rust 헬퍼는 단독 검증 가능. classify_unit는 파라미터 unit 문자열 → AU parameter unit enum 매핑이라 호스트가 보이는 표시 단위에 직접 영향. - Decibels: 'dB', 'decibel', 'dBFS' - Hertz: 'Hz', 'kHz', 'hertz' - Percent: '%', 'percent' - Seconds: 'ms', 'sec', 'seconds' - Generic: '', 'ratio', 'semitones' (fallback) 이 5건은 cxt registry의 'untested 2293' 카운트를 실측 가능한 만큼 줄여주는 첫 발판. --- src/wrapper/au/wrapper.rs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 6832e3a74..0f9c1dfb1 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1583,3 +1583,42 @@ unsafe fn zero_buffer_list(bl: *mut au::AudioBufferList, n_frames: au::UInt32) { unsafe { ptr::write_bytes(buf.mData as *mut f32, 0, n_samples) }; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[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("dBFS"), au::kAudioUnitParameterUnit_Decibels); + } + + #[test] + fn classify_unit_hertz() { + assert_eq!(classify_unit("Hz"), au::kAudioUnitParameterUnit_Hertz); + assert_eq!(classify_unit("kHz"), au::kAudioUnitParameterUnit_Hertz); + assert_eq!(classify_unit("hertz"), au::kAudioUnitParameterUnit_Hertz); + } + + #[test] + fn classify_unit_percent() { + assert_eq!(classify_unit("%"), 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); + } + + #[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); + } +} From 493950e650a26f5bead19b812d3e458f0102639d Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 22:12:18 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat(xtask):=20AUD-341=20=E2=80=94=20AU?= =?UTF-8?q?=20=EB=B2=88=EB=93=A4=20=EA=B0=90=EC=A7=80=20=EB=B0=8F=20.compo?= =?UTF-8?q?nent=20=EB=B0=B0=EC=B9=98=20(1/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nih_plug_au_factory 심볼이 export된 dylib을 macOS 타겟에서 빌드한 경우 {name}.component/Contents/MacOS/{name} 레이아웃으로 배치한다. VST3와 같은 셸 구조를 따르지만 .component 확장자라 CoreAudio component manager가 스캔한다. Info.plist는 일단 maybe_create_macos_bundle_metadata가 만드는 공통 BNDL 템플릿을 그대로 쓴다. 다음 커밋(2/2)에서 AudioComponents 배열을 추가해 호스트가 type/subtype/manufacturer로 컴포넌트를 식별할 수 있게 확장한다 — 그 단계 전에는 auval/DAW에서 보이지 않는다. --- nih_plug_xtask/src/lib.rs | 44 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/nih_plug_xtask/src/lib.rs b/nih_plug_xtask/src/lib.rs index 71003bc73..1e86327aa 100644 --- a/nih_plug_xtask/src/lib.rs +++ b/nih_plug_xtask/src/lib.rs @@ -426,7 +426,13 @@ fn bundle_plugin( .with_context(|| format!("Could not parse '{}'", first_lib_path.display()))?; let bundle_vst3 = symbols::exported(first_lib_path, "GetPluginFactory") .with_context(|| format!("Could not parse '{}'", first_lib_path.display()))?; - let bundled_plugin = bundle_clap || bundle_vst2 || bundle_vst3; + // Audio Unit (macOS only). The factory function name is fixed by `nih_export_au!`. + let bundle_au = matches!( + compilation_target, + CompilationTarget::MacOS(_) | CompilationTarget::MacOSUniversal + ) && symbols::exported(first_lib_path, "nih_plug_au_factory") + .with_context(|| format!("Could not parse '{}'", first_lib_path.display()))?; + let bundled_plugin = bundle_clap || bundle_vst2 || bundle_vst3 || bundle_au; if bundle_clap { let clap_bundle_library_name = clap_bundle_library_name(&bundle_name, compilation_target); @@ -511,6 +517,28 @@ fn bundle_plugin( eprintln!("Created a VST3 bundle at '{}'", vst3_bundle_home.display()); } + if bundle_au { + let au_lib_path = + bundle_home_dir.join(au_bundle_library_name(&bundle_name, compilation_target)); + + fs::create_dir_all(au_lib_path.parent().unwrap()) + .context("Could not create AU bundle directory")?; + util::reflink_or_combine(lib_paths, &au_lib_path, compilation_target) + .context("Could not create AU bundle")?; + + // `{name}.component/Contents/MacOS/{name}` → bundle home is the `.component` dir. + let au_bundle_home = au_lib_path.parent().unwrap().parent().unwrap().parent().unwrap(); + maybe_create_macos_bundle_metadata( + package, + &bundle_name, + au_bundle_home, + compilation_target, + BundleType::Plugin, + )?; + maybe_codesign(au_bundle_home, compilation_target); + + eprintln!("Created an AU bundle at '{}'", au_bundle_home.display()); + } if !bundled_plugin { eprintln!("Not creating any plugin bundles because the package does not export any plugins") } @@ -727,6 +755,20 @@ fn vst3_bundle_library_name(package: &str, target: CompilationTarget) -> String } } +/// The full path to the library file inside of an Audio Unit bundle, including the leading +/// `.component` directory. Audio Units are macOS-only, so this panics on other targets. +/// +/// Layout: `{name}.component/Contents/MacOS/{name}` — same shape as VST3 on macOS, but with +/// the `.component` extension that CoreAudio's component manager scans for. +fn au_bundle_library_name(package: &str, target: CompilationTarget) -> String { + match target { + CompilationTarget::MacOS(_) | CompilationTarget::MacOSUniversal => { + format!("{package}.component/Contents/MacOS/{package}") + } + _ => panic!("Audio Units are only supported on macOS"), + } +} + /// If compiling for macOS, create all of the bundl-y stuff Steinberg and Apple require you to have. /// /// This still requires you to move the dylib file to `{bundle_home}/Contents/macOS/{package}` From dc59b68514332adc73e3fbd34b4a03dc6aec5e24 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 22:14:17 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat(xtask):=20AUD-341=20=E2=80=94=20Audi?= =?UTF-8?q?oComponents=20Info.plist=20+=20bundler.toml=20AU=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20(2/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundler.toml의 [{package}.au] 섹션에서 type/subtype/manufacturer 4cc와 선택적 description/version을 읽어 AU 전용 Info.plist를 생성한다. 핵심 키: - factoryFunction = nih_plug_au_factory (nih_export_au!이 export하는 심볼) - type/subtype/manufacturer (4cc 문자열) - version (u32, major.minor.patch에서 인코딩) - sandboxSafe = true 크로스 컴파일된 dylib을 호스트가 dlopen할 수 없으므로 AuPlugin trait의 const와 bundler.toml 값이 중복된다 — 불가피한 비용. 검증: - 4cc 길이/ASCII 강제 (validate_fourcc) - 버전 범위 강제 (parse_au_version: major<=65535, minor/patch<=255) - 5개 unit test 추가, 모두 통과 이로써 .component 번들이 auval과 호스트가 인식 가능한 형태로 생성된다. 다음 단계(AUD-342)는 실제 plugin에 AuPlugin 적용 + bundler.toml 메타데이터 기입. --- nih_plug_xtask/src/lib.rs | 211 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 202 insertions(+), 9 deletions(-) diff --git a/nih_plug_xtask/src/lib.rs b/nih_plug_xtask/src/lib.rs index 1e86327aa..5dc84e277 100644 --- a/nih_plug_xtask/src/lib.rs +++ b/nih_plug_xtask/src/lib.rs @@ -34,6 +34,31 @@ type BundlerConfig = HashMap; #[derive(Debug, Clone, Deserialize)] struct PackageConfig { name: Option, + /// Audio Unit metadata. Required for the AU bundler to emit a usable + /// `AudioComponents` entry in Info.plist; without it the host cannot + /// instantiate the plugin even though the bundle exists on disk. + au: Option, +} + +/// Audio Unit-specific metadata for `bundler.toml`. Mirrors the fields the +/// `AuPlugin` trait exposes in the source — the duplication is unavoidable +/// because cross-compiled dylibs cannot be dlopen'd by the bundler at build +/// time to read the constants. +#[derive(Debug, Clone, Deserialize)] +struct AuPackageConfig { + /// Four-character type code (e.g. `"aufx"`, `"aumu"`). + #[serde(rename = "type")] + au_type: String, + /// Four-character subtype code unique within the manufacturer's product line. + subtype: String, + /// Four-character manufacturer code. + manufacturer: String, + /// Optional human-readable description shown by some hosts. + #[serde(default)] + description: Option, + /// Plugin version as `"major.minor.patch"`. Defaults to `"1.0.0"`. + #[serde(default)] + version: Option, } /// The target we're generating a plugin for. This can be either the native target or a cross @@ -342,7 +367,7 @@ fn bundle_binary( ) -> Result<()> { let bundle_home_dir = bundle_home(target_dir); let bundle_name = match load_bundler_config()?.and_then(|c| c.get(package).cloned()) { - Some(PackageConfig { name: Some(name) }) => name, + Some(PackageConfig { name: Some(name), .. }) => name, _ => package.to_string(), }; @@ -406,7 +431,7 @@ fn bundle_plugin( ) -> Result<()> { let bundle_home_dir = bundle_home(target_dir); let bundle_name = match load_bundler_config()?.and_then(|c| c.get(package).cloned()) { - Some(PackageConfig { name: Some(name) }) => name, + Some(PackageConfig { name: Some(name), .. }) => name, _ => package.to_string(), }; @@ -518,6 +543,14 @@ fn bundle_plugin( eprintln!("Created a VST3 bundle at '{}'", vst3_bundle_home.display()); } if bundle_au { + let au_config = load_bundler_config()? + .and_then(|c| c.get(package).cloned()) + .and_then(|c| c.au) + .with_context(|| format!( + "Package '{package}' exports an Audio Unit factory but bundler.toml has no \ + [{package}.au] section. Add type/subtype/manufacturer four-character codes." + ))?; + let au_lib_path = bundle_home_dir.join(au_bundle_library_name(&bundle_name, compilation_target)); @@ -528,13 +561,7 @@ fn bundle_plugin( // `{name}.component/Contents/MacOS/{name}` → bundle home is the `.component` dir. let au_bundle_home = au_lib_path.parent().unwrap().parent().unwrap().parent().unwrap(); - maybe_create_macos_bundle_metadata( - package, - &bundle_name, - au_bundle_home, - compilation_target, - BundleType::Plugin, - )?; + create_au_bundle_metadata(package, &bundle_name, au_bundle_home, &au_config)?; maybe_codesign(au_bundle_home, compilation_target); eprintln!("Created an AU bundle at '{}'", au_bundle_home.display()); @@ -837,6 +864,131 @@ pub fn maybe_create_macos_bundle_metadata( Ok(()) } +/// Create the AU-specific Info.plist (with the required `AudioComponents` array) and PkgInfo. +/// Unlike VST3/CLAP, AU plugins need extra Info.plist keys so the host can identify and +/// instantiate the component without dlopen'ing the dylib first. +fn create_au_bundle_metadata( + package: &str, + display_name: &str, + bundle_home: &Path, + config: &AuPackageConfig, +) -> Result<()> { + validate_fourcc(&config.au_type, "type")?; + validate_fourcc(&config.subtype, "subtype")?; + validate_fourcc(&config.manufacturer, "manufacturer")?; + + let version_string = config.version.as_deref().unwrap_or("1.0.0"); + let version_int = parse_au_version(version_string)?; + let description = config + .description + .clone() + .unwrap_or_else(|| format!("{display_name} (NIH-plug)")); + let component_name = format!("{}: {display_name}", config.manufacturer); + + fs::create_dir_all(bundle_home.join("Contents")).context("Could not create Contents/")?; + fs::write( + bundle_home.join("Contents").join("PkgInfo"), + "BNDL????", + ) + .context("Could not create PkgInfo file")?; + fs::write( + bundle_home.join("Contents").join("Info.plist"), + format!(r#" + + + + + CFBundleExecutable + {display_name} + CFBundleIdentifier + com.nih-plug.{package} + CFBundleName + {display_name} + CFBundleDisplayName + {display_name} + CFBundlePackageType + BNDL + CFBundleSignature + ???? + CFBundleShortVersionString + {version_string} + CFBundleVersion + {version_string} + NSHumanReadableCopyright + + NSHighResolutionCapable + + AudioComponents + + + name + {component_name} + description + {description} + factoryFunction + nih_plug_au_factory + type + {ty} + subtype + {subty} + manufacturer + {manu} + version + {version_int} + sandboxSafe + + + + + +"#, + ty = config.au_type, + subty = config.subtype, + manu = config.manufacturer, + ), + ) + .context("Could not create Info.plist file")?; + + Ok(()) +} + +/// Validate that a string is exactly 4 ASCII characters — Apple's four-char-code requirement. +/// `field_name` is used in the error message. +fn validate_fourcc(s: &str, field_name: &str) -> Result<()> { + if s.len() != 4 || !s.is_ascii() { + anyhow::bail!( + "AU {field_name} must be exactly 4 ASCII characters, got '{s}' ({} bytes)", + s.len() + ); + } + Ok(()) +} + +/// Parse a "major.minor.patch" version string into the AU 32-bit integer encoding +/// `(major << 16) | (minor << 8) | patch`. Each component must fit in 8 bits except +/// `major` which gets 16 bits. +fn parse_au_version(s: &str) -> Result { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 3 { + anyhow::bail!("AU version must be 'major.minor.patch', got '{s}'"); + } + let major: u32 = parts[0] + .parse() + .with_context(|| format!("AU version major component '{}' is not a number", parts[0]))?; + let minor: u32 = parts[1] + .parse() + .with_context(|| format!("AU version minor component '{}' is not a number", parts[1]))?; + let patch: u32 = parts[2] + .parse() + .with_context(|| format!("AU version patch component '{}' is not a number", parts[2]))?; + if major > 0xFFFF || minor > 0xFF || patch > 0xFF { + anyhow::bail!( + "AU version components out of range (major <= 65535, minor/patch <= 255), got '{s}'" + ); + } + Ok((major << 16) | (minor << 8) | patch) +} + /// If compiling for macOS, try to self-sign the bundle at the given path. This shouldn't be /// necessary, but AArch64 macOS is stricter about these things and sometimes self built plugins may /// not load otherwise. Presumably in combination with hardened runtimes. @@ -864,3 +1016,44 @@ pub fn maybe_codesign(bundle_home: &Path, target: CompilationTarget) { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fourcc_accepts_four_ascii() { + assert!(validate_fourcc("aufx", "type").is_ok()); + assert!(validate_fourcc("Aud1", "subtype").is_ok()); + assert!(validate_fourcc("MyCo", "manufacturer").is_ok()); + } + + #[test] + fn fourcc_rejects_wrong_length() { + assert!(validate_fourcc("auf", "type").is_err()); + assert!(validate_fourcc("aufxx", "type").is_err()); + assert!(validate_fourcc("", "type").is_err()); + } + + #[test] + fn fourcc_rejects_non_ascii() { + // "café" — 'é' is multi-byte UTF-8 so .len() == 5 anyway; check a 4-char non-ASCII case + assert!(validate_fourcc("aufé", "type").is_err()); + } + + #[test] + fn parse_version_basic() { + assert_eq!(parse_au_version("1.0.0").unwrap(), 0x0001_0000); + assert_eq!(parse_au_version("0.1.3").unwrap(), 0x0000_0103); + assert_eq!(parse_au_version("2.5.10").unwrap(), 0x0002_050A); + } + + #[test] + fn parse_version_rejects_malformed() { + assert!(parse_au_version("1.0").is_err()); + assert!(parse_au_version("1.0.0.0").is_err()); + assert!(parse_au_version("a.b.c").is_err()); + // patch overflow (256 > 255) + assert!(parse_au_version("1.0.256").is_err()); + } +} From 4aed21c4d2178387e144ba159b2413ea5103ff60 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 23:23:46 +0900 Subject: [PATCH 18/21] =?UTF-8?q?feat(gain):=20AUD-342=20=E2=80=94=20gain?= =?UTF-8?q?=20=EC=98=88=EC=A0=9C=EC=97=90=20AuPlugin=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20(=EC=B2=AB=20.component=20=EC=82=B0=EC=B6=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nih-plug AU 래퍼의 첫 실측 산출물. gain 예제는 stereo gain effect로 MIDI/SysEx 없는 가장 표준적인 effect 패턴이라 AU 도입 sanity check에 적합. 변경: - plugins/examples/gain/Cargo.toml: au feature 정의 (default, nih_plug/au forwarding). macOS 외 플랫폼에서는 wrapper의 macOS gate가 no-op으로 만든다 - plugins/examples/gain/src/lib.rs: impl AuPlugin (4cc 상수 — type=aufx, subtype=MPgN, manufacturer=MoiP), nih_export_au!(Gain). cfg(feature='au', target_os='macos') 게이트로 보호 - bundler.toml: [gain.au] 추가 (type/subtype/manufacturer/description) + 상단 주석에 AU 메타데이터 형식 안내 검증: - cargo xtask bundle gain --release 실행 → target/bundled/Gain.component 생성 성공 - 번들 구조: Contents/MacOS/Gain (dylib) + Contents/Info.plist (AudioComponents 배열) + Contents/_CodeSignature - export 심볼: _nih_plug_au_factory, _clap_entry, _GetPluginFactory 모두 확인 - Info.plist의 version=65536 = (1<<16) — 1.0.0 인코딩 검증 다음 단계 (AUD-343): auval -v aufx MPgN MoiP로 Apple 공식 검증 통과. --- bundler.toml | 18 ++++++++++++++++++ plugins/examples/gain/Cargo.toml | 7 +++++++ plugins/examples/gain/src/lib.rs | 14 ++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/bundler.toml b/bundler.toml index 6366871d5..3b7f39c7c 100644 --- a/bundler.toml +++ b/bundler.toml @@ -3,6 +3,24 @@ # # [package_name] # name = "Human Readable Plugin Name" # defaults to +# +# Audio Unit-specific metadata (macOS only) goes under [package_name.au]: +# +# [package_name.au] +# type = "aufx" # 4cc: aufx (effect), aumu (instrument), aumf (MIDI fx), augn (generator) +# subtype = "Abcd" # 4cc unique within manufacturer's product line +# manufacturer = "MyCo" # 4cc manufacturer code +# description = "..." # optional, shown by some hosts +# version = "1.0.0" # optional, defaults to "1.0.0" + +[gain] +name = "Gain" + +[gain.au] +type = "aufx" +subtype = "MPgN" +manufacturer = "MoiP" +description = "Smoothed gain — nih-plug AU example" [soft_vacuum] name = "Soft Vacuum" diff --git a/plugins/examples/gain/Cargo.toml b/plugins/examples/gain/Cargo.toml index 7317a843e..c191c9cdd 100644 --- a/plugins/examples/gain/Cargo.toml +++ b/plugins/examples/gain/Cargo.toml @@ -8,6 +8,13 @@ license = "ISC" [lib] crate-type = ["cdylib"] +[features] +default = ["au"] +# Forwards to nih_plug's `au` feature so cfg(feature = "au") in lib.rs is +# meaningful in this crate. The wrapper module is itself macOS-gated, so on +# other platforms enabling this is a no-op (au-sys etc. are macOS-only deps). +au = ["nih_plug/au"] + [dependencies] nih_plug = { path = "../../../", features = ["assert_process_allocs"] } diff --git a/plugins/examples/gain/src/lib.rs b/plugins/examples/gain/src/lib.rs index 726c27a4f..d393bbd28 100644 --- a/plugins/examples/gain/src/lib.rs +++ b/plugins/examples/gain/src/lib.rs @@ -210,5 +210,19 @@ impl Vst3Plugin for Gain { &[Vst3SubCategory::Fx, Vst3SubCategory::Tools]; } +#[cfg(all(feature = "au", target_os = "macos"))] +impl AuPlugin for Gain { + // `aufx` = audio effect. `MPgN` = Moist Plugins Gain (subtype, must be + // unique within this manufacturer). `MoiP` = Moist Plugins (manufacturer + // 4cc). These three values must match the `[gain.au]` block in + // `bundler.toml` — they are duplicated because the bundler can't dlopen + // a cross-compiled dylib to read the constants at build time. + const AU_TYPE: [u8; 4] = *b"aufx"; + const AU_SUBTYPE: [u8; 4] = *b"MPgN"; + const AU_MANUFACTURER: [u8; 4] = *b"MoiP"; +} + nih_export_clap!(Gain); nih_export_vst3!(Gain); +#[cfg(all(feature = "au", target_os = "macos"))] +nih_export_au!(Gain); From daf34e161f6aedfad4a1a196f9f97eefc9fd666e Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 9 May 2026 23:29:36 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix(xtask):=20AU=20Info.plist=EC=97=90=20?= =?UTF-8?q?=ED=91=9C=EC=A4=80=20=ED=82=A4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 작동하는 third-party AU(예: Truce*)와 비교한 결과, 우리 plist에 다음 표준 키가 누락되어 있어 추가: - (DTD 호환) - CFBundleInfoDictionaryVersion = 6.0 (Apple 권장) - LSMinimumSystemVersion = 11.0 (Launch Services 호환) 또 XML 선언 다음의 빈 줄을 제거 — plutil은 lenient라 통과시키지만 일부 파서는 더 엄격할 수 있음. 이 변경만으로 auval 인식 문제가 해결되지는 않았지만(시스템 측의 AU registry 이슈 별도) plist 표준 준수는 옳은 방향이라 선반영. --- nih_plug_xtask/src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nih_plug_xtask/src/lib.rs b/nih_plug_xtask/src/lib.rs index 5dc84e277..9f2eedf21 100644 --- a/nih_plug_xtask/src/lib.rs +++ b/nih_plug_xtask/src/lib.rs @@ -894,14 +894,15 @@ fn create_au_bundle_metadata( fs::write( bundle_home.join("Contents").join("Info.plist"), format!(r#" - - + CFBundleExecutable {display_name} CFBundleIdentifier com.nih-plug.{package} + CFBundleInfoDictionaryVersion + 6.0 CFBundleName {display_name} CFBundleDisplayName @@ -914,6 +915,8 @@ fn create_au_bundle_metadata( {version_string} CFBundleVersion {version_string} + LSMinimumSystemVersion + 11.0 NSHumanReadableCopyright NSHighResolutionCapable From 95feaef23f78867ec67bf828027bd33715fc32bc Mon Sep 17 00:00:00 2001 From: unohee Date: Mon, 11 May 2026 12:51:05 +0900 Subject: [PATCH 20/21] =?UTF-8?q?diagnose(au):=20AUD-343=20=E2=80=94=20ren?= =?UTF-8?q?der=20=EC=B2=AB=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5/=EC=B6=9C=EB=A0=A5=20=EC=B6=94=EC=A0=81=20(=EC=9D=BC?= =?UTF-8?q?=ED=9A=8C=EC=84=B1=20=EC=A7=84=EB=8B=A8=20=EC=BD=94=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AU host의 입력 전달 모델과 오디오 처리 상태를 파악하기 위해 render() 첫 호출에서 다음을 기록한다: - 프레임 수, 버퍼 개수 - input callback 존재 여부 - bypass 상태 - 첫 입력/출력 샘플값 이 코드는 검증 완료 후 제거할 일회성 진단 코드다. Refs: AUD-343 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/wrapper/au/wrapper.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 0f9c1dfb1..a85a7f4a1 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1400,6 +1400,32 @@ impl Wrapper

{ // 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. + // ── DIAGNOSTIC: one-shot render trace (AUD-343 PluginDoctor 무음 진단) ─ + // 첫 render() 호출에서 한 번만 찍는다. AU host의 input 전달 모델 파악용. + // 검증 끝나면 통째로 제거할 일회성 코드. + { + use std::sync::atomic::AtomicBool; + static FIRST: AtomicBool = AtomicBool::new(true); + if FIRST.swap(false, Ordering::Relaxed) { + let cb_present = callback_snapshot.is_some(); + let bypass = this.bypass.load(Ordering::Acquire); + let n_ch_buf = unsafe { (*io_data).mNumberBuffers }; + let first_sample_in: f32 = unsafe { + let buf = &*buffers_ptr; + if !buf.mData.is_null() && n_frames > 0 { + *(buf.mData as *const f32) + } else { + f32::NAN + } + }; + eprintln!( + "[nih-plug AU] render#1: n_frames={n_frames} n_buffers={n_ch_buf} \ + pulled_from_callback={pulled_from_callback} input_callback_present={cb_present} \ + bypass={bypass} first_input_sample={first_sample_in:?}" + ); + } + } + if !this.bypass.load(Ordering::Acquire) { // SAFETY: render is not concurrent with Initialize/Uninitialize/Reset // and AU does not re-enter render, so this `&mut P` is unique. @@ -1408,6 +1434,25 @@ impl Wrapper

{ &mut aux, &mut process_ctx, ); + + // ── DIAGNOSTIC: post-process first output sample ────────────── + { + use std::sync::atomic::AtomicBool; + static FIRST_OUT: AtomicBool = AtomicBool::new(true); + if FIRST_OUT.swap(false, Ordering::Relaxed) { + let first_sample_out: f32 = unsafe { + let buf = &*buffers_ptr; + if !buf.mData.is_null() && n_frames > 0 { + *(buf.mData as *const f32) + } else { + f32::NAN + } + }; + eprintln!( + "[nih-plug AU] render#1 post-process: first_output_sample={first_sample_out:?}" + ); + } + } } // Clear the slot slices so the `'static` lifetime can never escape From ef69e7ce9dcfc693530dbaabf1f8bb90958b6fbf Mon Sep 17 00:00:00 2001 From: unohee Date: Mon, 11 May 2026 13:00:10 +0900 Subject: [PATCH 21/21] =?UTF-8?q?refactor(au):=20AUD-342=20=E2=80=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20doc=20=EA=B0=B1=EC=8B=A0=20+=20=EC=A3=BD?= =?UTF-8?q?=EC=9D=80=20=EC=BD=94=EB=93=9C(PluginInfo)=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20+=20=EA=B2=BD=EA=B3=A0=200=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - au.rs, wrapper.rs 모듈 doc을 Phase 1 → 완전한 AUv2 구현 상태로 갱신 - factory.rs에서 미사용 PluginInfo 구조체 제거 (다중 플러그인 매크로 미완성 인프라; 현재 nih_export_au! 어디서도 참조 안 함) - classify_unit()의 "ms" → kAudioUnitParameterUnit_Seconds 매핑에 주석 추가 — AU에 _Milliseconds 없음을 명시 - clap/util.rs: unsafe_clap_call re-export에 #[allow(unused_imports)] 추가 (--features au 빌드 시 clap wrapper가 컴파일되지 않아 발생하는 경고 제거) `cargo build --features au`: 경고 0건 (objc2 cfg 노이즈 제외) Refs: AUD-342 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/wrapper/au.rs | 16 +++++----------- src/wrapper/au/factory.rs | 31 +------------------------------ src/wrapper/au/wrapper.rs | 15 ++++++++++----- src/wrapper/clap/util.rs | 1 + 4 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/wrapper/au.rs b/src/wrapper/au.rs index 23bca5684..f782f76da 100644 --- a/src/wrapper/au.rs +++ b/src/wrapper/au.rs @@ -1,21 +1,15 @@ //! Apple Audio Unit (AUv2) wrapper for nih-plug. //! -//! Phase 1 (current): minimum viable AU — registers via -//! `AudioComponentPlugInInterface`, exposes the basic property selectors -//! (`SampleRate`, `StreamFormat`, `Latency`, `MaximumFramesPerSlice`, -//! `SupportedNumChannels`), and renders silence. `auval` should validate -//! the component end-to-end at this stage. -//! -//! Phase 2: parameters + state. -//! Phase 3: real `Plugin::process` render path. -//! Phase 4: Cocoa view via `objc2-app-kit`. -//! Phase 5: bundle generation in `nih_plug_xtask`. +//! Implements a complete AUv2 plugin via `AudioComponentPlugInInterface`: +//! parameter hosting, stream format negotiation, real `Plugin::process` render, +//! bypass, property listeners, and transport/tempo via `HostCallbackInfo`. +//! Bundle generation lives in `nih_plug_xtask` (`bundle-au` subcommand). mod context; mod factory; mod wrapper; -pub use factory::{fourcc, PluginInfo}; +pub use factory::fourcc; pub use wrapper::{AuWrapper, Wrapper}; // Re-export `au-sys` so the macro can refer to its types without a diff --git a/src/wrapper/au/factory.rs b/src/wrapper/au/factory.rs index 277437ec8..4b1fa8e19 100644 --- a/src/wrapper/au/factory.rs +++ b/src/wrapper/au/factory.rs @@ -1,33 +1,4 @@ -//! Static metadata describing an AU plugin. Mirrors the role of `PluginInfo` -//! in the VST3 wrapper — collects everything the AU host needs to identify -//! and instantiate the plugin so it can be type-erased and stored in an -//! array (allowing `nih_export_au!` to export multiple plugins eventually). - -/// Metadata for one exported AU plugin. -#[derive(Debug)] -pub struct PluginInfo { - /// Plugin name, used for the `Info.plist`'s `CFBundleName` and the - /// `AudioComponents` array entries. - pub name: &'static str, - /// Manufacturer / vendor display name. - pub vendor: &'static str, - /// Plugin version string ("0.1.3"). Used for `CFBundleShortVersionString`. - pub version: &'static str, - /// Optional URL exposed via the manufacturer info. - pub url: &'static str, - /// Optional contact email. - pub email: &'static str, - - /// Audio Unit type (e.g. `*b"aufx"`). Internally stored as `u32` in - /// big-endian byte order — the order Apple's CoreAudio APIs use. - pub au_type: u32, - /// Audio Unit subtype four-char code. - pub au_subtype: u32, - /// Audio Unit manufacturer four-char code. - pub au_manufacturer: u32, - /// 32-bit version number `(major << 16) | (minor << 8) | patch`. - pub au_version: u32, -} +//! Utility helpers for the AU wrapper. /// Build a `u32` four-char code from a 4-byte array. /// diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index a85a7f4a1..ed2e123bc 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1,9 +1,11 @@ -//! AU wrapper, Phase 3.5. +//! AU wrapper — full render pipeline (AUv2). //! -//! Hosts the plugin's `Params` so: -//! - `kAudioUnitProperty_ParameterList` returns the AU parameter ID array -//! - `kAudioUnitProperty_ParameterInfo` returns name / range / default / unit -//! - `AudioUnitGet/SetParameter` hand off to the underlying nih-plug `ParamPtr` +//! Implements the complete AUv2 plugin lifecycle: +//! - Parameter hosting: `ParameterList`, `ParameterInfo`, `Get/SetParameter` +//! - Stream format negotiation and `Initialize` / `Uninitialize` +//! - `Render` with input-callback pull, bypass, and `Plugin::process` +//! - Property listeners, transport / tempo via `HostCallbackInfo` +//! - `BypassEffect` property with `ParamFlags::BYPASS` sync //! //! AU parameter IDs are simply the index into `param_map()` — stable across //! one binary build (the nih-plug derive guarantees field declaration order). @@ -1604,6 +1606,9 @@ fn classify_unit(unit: &str) -> au::AudioUnitParameterUnit { } else if lower.contains('%') || lower.contains("percent") { au::kAudioUnitParameterUnit_Percent } else if lower.contains("ms") || lower.contains("sec") || lower.contains("second") { + // AU has no kAudioUnitParameterUnit_Milliseconds; map "ms" to Seconds. + // Hosts display the raw value, so plugins must expose values in seconds + // when they want AU-native time display. au::kAudioUnitParameterUnit_Seconds } else { au::kAudioUnitParameterUnit_Generic diff --git a/src/wrapper/clap/util.rs b/src/wrapper/clap/util.rs index b394dad12..06b59aa50 100644 --- a/src/wrapper/clap/util.rs +++ b/src/wrapper/clap/util.rs @@ -56,6 +56,7 @@ pub fn type_name_of_ptr(_ptr: *const T) -> &'static str { pub(crate) use check_null_ptr_msg; pub(crate) use clap_call; +#[allow(unused_imports)] pub(crate) use unsafe_clap_call; /// Send+Sync wrapper around CLAP host extension pointers.