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 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/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/nih_plug_xtask/src/lib.rs b/nih_plug_xtask/src/lib.rs index 71003bc73..9f2eedf21 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(), }; @@ -426,7 +451,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 +542,30 @@ 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)); + + 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(); + 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()); + } if !bundled_plugin { eprintln!("Not creating any plugin bundles because the package does not export any plugins") } @@ -727,6 +782,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}` @@ -795,6 +864,134 @@ 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} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {display_name} + CFBundleDisplayName + {display_name} + CFBundlePackageType + BNDL + CFBundleSignature + ???? + CFBundleShortVersionString + {version_string} + CFBundleVersion + {version_string} + LSMinimumSystemVersion + 11.0 + 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. @@ -822,3 +1019,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()); + } +} 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); 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/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..f782f76da --- /dev/null +++ b/src/wrapper/au.rs @@ -0,0 +1,59 @@ +//! Apple Audio Unit (AUv2) wrapper for nih-plug. +//! +//! 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; +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/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/factory.rs b/src/wrapper/au/factory.rs new file mode 100644 index 000000000..4b1fa8e19 --- /dev/null +++ b/src/wrapper/au/factory.rs @@ -0,0 +1,12 @@ +//! Utility helpers for the AU wrapper. + +/// 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..ed2e123bc --- /dev/null +++ b/src/wrapper/au/wrapper.rs @@ -0,0 +1,1674 @@ +//! AU wrapper — full render pipeline (AUv2). +//! +//! 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). +//! +//! # 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::mem; +use std::num::NonZeroU32; +use std::ptr; +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; +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. +/// +/// The first field MUST be the vtable, since Apple's component manager +/// dispatches through `instance->vtable->Lookup(selector)`. +#[repr(C)] +pub struct Wrapper { + /// Apple-required vtable. MUST be the first field. + vtable: au::AudioComponentPlugInInterface, + + /// 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`. f64 bits. + sample_rate_bits: AtomicU64, + + /// Maximum frames per `AudioUnitRender` slice. + max_frames_per_slice: AtomicU32, + + /// Channel count set via `StreamFormat`. + n_channels: AtomicU32, + + /// Latency reported via `kAudioUnitProperty_Latency`. f64 bits. + latency_seconds_bits: AtomicU64, + + /// Whether `Plugin::initialize()` has run successfully since the last + /// `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, + + /// 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. + /// + /// 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. 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, + + /// 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. + /// + /// `AURenderCallbackStruct` is `Copy`. Audio thread snapshots out under + /// the mutex at the start of render and releases immediately. + input_callback: Mutex>, + + /// 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. + 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 { + /// Stable string ID — currently unused but kept for future state save/load. + #[allow(dead_code)] + id_str: String, + 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`. +/// - `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()); + 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(); + + // 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, + Close: Self::close, + Lookup: Self::lookup, + reserved: ptr::null_mut(), + }, + 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), + bypass: AtomicBool::new(false), + 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), + listeners: Mutex::new(Vec::new()), + pending_notifications: AtomicU32::new(0), + render_state: UnsafeCell::new(RenderState::new()), + }); + Box::into_raw(boxed) as *mut au::AudioComponentPlugInInterface + } + + /// 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) + } + + /// 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 + /// 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()` 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 render_state_mut(&self) -> &mut RenderState { + unsafe { &mut *self.render_state.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 + .store(instance as usize as u64, Ordering::Release); + au::noErr + } + + unsafe extern "C" fn close(self_ptr: *mut c_void) -> au::OSStatus { + unsafe { + let _ = Box::from_raw(self_ptr as *mut Self); + } + au::noErr + } + + unsafe extern "C" fn lookup(selector: au::SInt16) -> Option { + 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) + }, + 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) + } + + /// 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) }; + + 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: sr as f32, + min_buffer_size: None, + max_buffer_size: max_frames, + process_mode: ProcessMode::Realtime, + }; + + let mut ctx = AuInitContext::

{ + sink: this.sink.clone(), + _marker: PhantomData, + }; + // 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.reset(); + + // 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 { + 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 + } + + unsafe extern "C" fn uninitialize(self_ptr: *mut c_void) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + 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 + } + + unsafe extern "C" fn reset( + self_ptr: *mut c_void, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + // SAFETY: AU calls Reset on the main thread, serialised against render. + unsafe { this.plugin_mut() }.reset(); + 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, + 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) }; + + 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 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 => + { + 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::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, + } + } + + 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) }; + // 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; + } + + 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_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 + } + au::kAudioUnitProperty_SupportedNumChannels + if scope == au::kAudioUnitScope_Global => + { + if (unsafe { *io_data_size } as usize) < std::mem::size_of::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + // Report the first declared layout's main channel count. + // If multiple layouts are declared we currently only expose + // one entry; auval accepts this as a conservative answer. + let info = match P::AUDIO_IO_LAYOUTS.iter().next() { + Some(layout) => { + let in_ch = layout + .main_input_channels + .map(|n| n.get() as i16) + .unwrap_or(0); + let out_ch = layout + .main_output_channels + .map(|n| n.get() as i16) + .unwrap_or(0); + au::AUChannelInfo { + inChannels: in_ch, + outChannels: out_ch, + } + } + 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 + } + 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) = on; + *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::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, + } + } + + 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) }; + 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 + } + + fn set_property_impl( + this: &Self, + id: au::AudioUnitPropertyID, + scope: au::AudioUnitScope, + 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::() { + 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 + } + au::kAudioUnitProperty_StreamFormat => { + if (in_data_size as usize) + < std::mem::size_of::() + { + 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(req_ch, Ordering::Release); + this.mark_pending(NOTIFY_STREAM_FORMAT); + 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 + } + 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; + 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 { + 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::() { + return au::kAudioUnitErr_InvalidPropertyValue; + } + let cb = unsafe { *(in_data as *const au::AURenderCallbackStruct) }; + 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, + 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::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, + } + } + + /// 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, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + out_value: *mut au::AudioUnitParameterValue, + ) -> au::OSStatus { + 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. + /// + /// # 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, + _scope: au::AudioUnitScope, + _element: au::AudioUnitElement, + value: au::AudioUnitParameterValue, + _buffer_offset_in_frames: au::UInt32, + ) -> au::OSStatus { + 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); + } + // 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 + } + + /// Render hot path. Convert the host's `AudioBufferList` into the + /// nih-plug `Buffer` shape and dispatch to `Plugin::process`. + /// + /// 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, + _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; + } + + if !this.is_initialized() { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + return au::noErr; + } + + let n_frames = in_number_frames as usize; + + // Snapshot the input callback under the mutex (cheap struct copy). + // 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 = 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 { + (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 = rs.input_scratch[ch].as_mut_ptr(); + unsafe { + *buffers_ptr.add(ch) = au::AudioBuffer { + mNumberChannels: 1, + mDataByteSize: (n_frames * mem::size_of::()) + as au::UInt32, + mData: scratch_ptr as *mut c_void, + }; + } + } + + let ts: au::AudioTimeStamp = unsafe { mem::zeroed() }; + let mut flags: au::AudioUnitRenderActionFlags = 0; + let proc = cb.inputProc.unwrap(); + let status = unsafe { + proc( + cb.inputProcRefCon, + &mut flags, + &ts, + 0, + in_number_frames, + bl_ptr, + ) + }; + if status == au::noErr { + pulled_from_callback = true; + } + } + } + + // ── 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(); + + // 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(rs.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 = &rs.input_scratch[i][..n_frames]; + dst.copy_from_slice(src); + } + } + + // 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 { + 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 []; + } + }); + } + + 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 mut process_ctx = AuProcessContext::

{ + sink: this.sink.clone(), + transport, + _marker: PhantomData, + }; + + // 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. + // ── 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. + let _status = unsafe { this.plugin_mut() }.process( + &mut rs.buffer, + &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 + // 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 { + 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, + ) -> 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, + ) -> 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 + } +} + +/// Build the `AudioUnitParameterInfo` blob the host queries for each +/// 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() }; + + 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; + + 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 = 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 + | au::kAudioUnitParameterFlag_IsWritable + | au::kAudioUnitParameterFlag_CanRamp; + + 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(); + 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. +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") { + 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 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 + } +} + +pub use Wrapper as AuWrapper; + +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) }; + } +} + +#[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); + } +} 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.