Audio Unit v3 (AUAudioUnit) format adapter for Pulp — render-block wiring, parameter tree bridging, MIDI / sysex via AURenderEvent, sidechain pulls, state persistence, iOS extension surface, and the pitfalls discovered while wiring the adapter.
Scanned 6/11/2026
Install via CLI
openskills install danielraffel/pulp---
name: auv3
description: Audio Unit v3 (AUAudioUnit) format adapter for Pulp — render-block wiring, parameter tree bridging, MIDI / sysex via AURenderEvent, sidechain pulls, state persistence, iOS extension surface, and the pitfalls discovered while wiring the adapter.
---
# AU v3 Skill
Use this skill when touching Pulp's Audio Unit v3 adapter, when
answering questions about how a Pulp plugin behaves inside Logic Pro,
GarageBand, MainStage, AUM, Cubasis, or any AUv3-aware iOS host, or
when an `auval` run fails. AU v3 is one of Pulp's three first-class
first-party formats; unlike AU v2 (which is owned by the `view-bridge`
skill via `au_v2_adapter.cpp`), AU v3 is the modern `AUAudioUnit`
subclass surface.
## When to use
- Editing `core/format/src/au_adapter.mm` — the `PulpAudioUnit`
`AUAudioUnit` subclass.
- Editing `core/format/src/au_entry.mm` — the
`AUAudioUnitFactory`-conforming entry object (`PulpAUFactoryObj`)
and the `PulpAUFactory` component entry symbol.
- Touching `core/format/src/au_audio_unit.h` — the Obj-C forward
declaration used by `au_entry.mm` and the iOS view controller.
- Editing the iOS extension view controller
(`core/format/src/au_view_controller_ios.mm`) — but first read the
`ios` and `view-bridge` skills; that file is **also** mapped to
them.
- An AUv3 host reports a behaviour issue — sidechain pull missing,
sysex dropped, parameter tree blank, preset recall fails, MIDI-out
not delivered.
- An `auval` pass regresses.
- Working on ARA-aware AU — but start with the `ara` skill; the AU
story there is the `audioUnitARAFactory` KVO property.
AU v2 (`core/format/src/au_v2_adapter.cpp`) is a **separate** adapter
covered by its own `auv2` skill. AU v2 is `AUEffectBase`-based and
used where hosts require the classic v2 Component Manager API. **Do
not edit `au_v2_adapter.cpp` as part of AU v3 work.**
## Files and entry points
| Role | Path |
|---|---|
| Core adapter (Obj-C++) | `core/format/src/au_adapter.mm` |
| Forward declaration used by entry / view | `core/format/src/au_audio_unit.h` |
| Component entry factory | `core/format/src/au_entry.mm` |
| iOS AUv3 extension view controller | `core/format/src/au_view_controller_ios.mm` (also mapped to `ios` + `view-bridge` skills) |
| iOS AU audio session helper | `core/format/src/ios_audio_session.cpp`, `core/format/include/pulp/format/ios_audio_session.{h,hpp}` |
| Info.plist template (AU component bundle) | `tools/cmake/PulpInfoPlist.au.in` |
| AudioUnitSDK fetch (used primarily by AU v2 but shared utilities reach AU v3) | `external/AudioUnitSDK` (Apache 2.0) |
| Tests | `test/test_ios_audio_session.cpp`, `test/test_ios_background_audio_flag.cpp` (iOS-specific); AU v3 shares state / processor tests with CLAP / VST3 |
| CLI validator invocation | `tools/cli/cmd_validate.cpp` — runs `auval` via the `auval-<name>` CTest target |
There is no `PulpAU3.cmake`; AU v3 targets are wired directly in the
top-level CMake plugin helpers alongside AU v2 and the iOS extension
target.
## Core conventions
### `AUAudioUnit`, not `AUEffectBase`
AU v3 uses `AUAudioUnit` as the plugin base class — Apple's modern,
block-based render API. AU v2 (`AUEffectBase`) and AU v3
(`AUAudioUnit`) are **two different C++ classes and two separate
`.component` bundles**. Pulp ships both where applicable; the
v3 subclass is `PulpAudioUnit` in `au_adapter.mm`.
The bridge struct `pulp::format::au::AUBridge` owns:
- `std::unique_ptr<Processor> processor` + `state::StateStore store`
— the same Pulp DSP + state objects used by the CLAP/VST3 adapters.
- Pre-allocated `output_ptrs`, `input_ptrs`, `sidechain_ptrs` (sized
to `kMaxChannels = 8`) so the render block never allocates.
- `InputBufferStorage input_abl` and `SidechainBufferStorage
sidechain_abl` — pre-sized `AudioBufferList` structs for the
`AURenderPullInputBlock` pulls. The sidechain has its **own** ABL
so it doesn't alias the main input pull.
- `sidechain_storage` — a `std::vector<float>` backing buffer for the
sidechain pull so the adapter can stay allocation-free on the audio
thread after `allocateRenderResources`.
- `param_events` (`state::ParameterEventQueue`) — cleared each render
block and filled from inbound AU parameter/ramp render events with
their sample offsets while the StateStore dual-write keeps normal
block-level parameter reads current.
### Entry point: `PulpAUFactory` + `AUAudioUnitFactory`
**macOS (Phase 3.5):** the .appex stub uses Apple's `_NSExtensionMain`
entry point. `NSExtensionPrincipalClass` in Info.plist points at
`PulpAUMacViewController`, which adopts `AUAudioUnitFactory` directly
— its `createAudioUnitWithComponentDescription:error:` allocs a
`PulpAudioUnit`. The legacy `PulpAUFactory` C component-registration
function is NOT used on macOS; everything goes through PlugInKit's
extension lifecycle. See the "macOS AU v3 packaging" section below
for the full framework + stub .appex + container .app architecture.
**iOS (legacy monolithic .appex):** `au_entry.mm` still defines
`PulpAUFactoryObj` (NSObject conforming to `<AUAudioUnitFactory>`) and
the C entry symbol `PulpAUFactory(const AudioComponentDescription*)`.
iOS uses the same `_NSExtensionMain` path via Apple's
`AUViewController`-based principal class
(`PulpAUViewController` in `au_view_controller_ios.mm`); the C entry
remains as a no-op safety net for `AudioComponentInstantiate`-style
direct loads. The `PULP_AUV3_PLUGIN()` macro (in
`<pulp/format/au_v3_entry.hpp>`) is what registers the per-plugin
processor factory at static init — no `force_link` shim required.
### Bus construction
Inside `initWithComponentDescription:…`:
- Output bus: one `AUAudioUnitBus` at 48 kHz default with
`desc.default_output_channels()` channels.
- Main input bus: when `desc.default_input_channels() > 0`, one
`AUAudioUnitBus` with that channel count.
- Sidechain bus: when `desc.input_buses.size() > 1` and
`desc.input_buses[1].default_channels > 0`, a **second** input
`AUAudioUnitBus` — bus index 1. Hosts connect their sidechain source
to bus index 1 and the render block pulls it from that index.
This mirrors the CLAP / VST3 "bus 0 = main, bus 1 = sidechain" rule;
see commit `8a960d51 auv3: sidechain input bus + render-block routing
(workstream 01 slice 1.4b)`.
### Parameters: `AUParameterTree`
`- (AUParameterTree *)parameterTree` builds one `AUParameter` per
`StateStore` param:
- `AUParameterAddress` is the Pulp `ParamID` cast to `uint64_t`.
- `unit` is mapped from Pulp's unit string
(`dB` → `kAudioUnitParameterUnit_Decibels`, `Hz` → `_Hertz`, `%` →
`_Percent`, boolean-shaped ranges → `_Boolean`, everything else →
`_Generic`).
- `implementorValueObserver` writes host param changes into
`store.set_value_rt(id, value)`. **AUv3 hosts may invoke the observer
from arbitrary threads, including the render thread** — use
`set_value_rt`, not the generic `set_value`. The RT path writes the
atomic and pushes an SPSC event for `ListenerThread::Main` listeners;
the editor drains via `store.pump_listeners()`. The generic path
would heap-allocate the dispatch lambda on a possibly-audio thread.
See Slice 2 in `planning/2026-05-18-rt-safety-and-debug-dx.md`.
- `implementorValueProvider` reads current values back from the store.
- `implementorStringFromValueCallback` delegates to
`ParamInfo::to_string` when provided, otherwise a `%.2f` fallback.
`__weak` capture + `strongSelf` null-check pattern is deliberate —
Obj-C blocks on `AUParameterTree` must not retain the audio unit.
### Render block
`- (AUInternalRenderBlock)internalRenderBlock` returns a block that
captures a raw `&_bridge` pointer (Obj-C `__block` / ARC semantics do
not apply — `_bridge` is a C++ struct). The block:
1. Zeroes outputs and returns `noErr` if the Processor is null (host
calling render before `allocateRenderResources` succeeded).
2. Rejects `frameCount > maximumFramesToRender` with
`kAudioUnitErr_TooManyFramesToProcess`. If a host or validator
passes null or undersized `outputData->mBuffers[i].mData`, the
adapter assigns slices from `AUBridge::output_storage`, which is
pre-sized in `allocateRenderResources`. Do not heap-allocate in the
steady-state render path.
3. Pulls the main input via `pullInputBlock(…, 0, &input_abl)`. This
reuses the **output** buffers as the input destination — in-place
processing is allowed (`canProcessInPlace` returns `YES`).
4. Pulls the sidechain (if enabled) via
`pullInputBlock(…, 1, &sidechain_abl)` into the separate backing
storage; publishes to `processor->set_sidechain(&view)` only on
success, nulls out the slot on failure.
5. Walks the realtime event list — parameter/ramp events append to
`param_events` and call `store.set_value_rt`, short MIDI arrives via
`AURenderEventMIDI`, and long / sysex arrives via
`AURenderEventMIDIEventList`. See gotchas below.
The sorted `param_events` queue is attached to the processor via
`set_param_events(¶m_events)` before render, so
`Processor::param_events()` exposes the same sample offsets.
6. Calls `processor->process(output_view, input_view, midi_in,
midi_out, ctx)`.
7. Forwards any `midi_out` events back to the host via
`self.MIDIOutputEventBlock` (AU v3.1+). Each event's
`sample_offset` is added to `timestamp->mSampleTime`.
### State: `fullState` dictionary
`fullState` wraps `store_.serialize()` bytes inside an `NSData` keyed
`@"pulpState"` within the dictionary returned by `super.fullState`.
`setFullState:` reads `@"pulpState"` back, calls
`store_.deserialize`. The super call is intentional — AUAudioUnit
merges its own internal state (e.g. maximum frames to render) into the
dictionary, and the round-trip must preserve it.
`supportsUserPresets` currently returns `NO`. `currentPreset` is not
overridden — use `fullState` for persistence, not
`AUAudioUnitPreset`. Wiring user presets requires implementing
`userPresets`, `supportsUserPresets`, `saveUserPreset:error:`,
`deleteUserPreset:error:`, `presetStateFor:error:`, and
`currentPreset` as a matched pair.
### ARA companion factory
`audioUnitARAFactory` is a `@property (readonly, nullable) void *` —
the AU-host-observed KVO property that ARA-aware hosts (Logic Pro 11+)
read during scan. It returns
`pulp::format::ara_companion_factory_for(nullptr)`, which is non-null
in `PULP_HAS_ARA` builds where a Processor overrode
`create_ara_document_controller()`. See commit `cb5812c1 ara(au):
audioUnitARAFactory KVO property on PulpAudioUnit (#252)`.
### iOS AUv3 extension
AUv3 on iOS is a UIKit **app extension**. The view controller
(`PulpAUViewController` in `au_view_controller_ios.mm`) is
`AUViewController`-derived and builds a `ViewBridge` against the
extension's loaded `AUAudioUnit` once KVO fires on
`self.audioUnit`. Extension principal class registration is via
`NSExtensionMain`-style Info.plist — see `docs/guides/ios-auv3-guidance.md`
and the `ios` skill for the extension target wiring.
### iOS AUv3 spawn-chain gotchas (learned the hard way 2026-05-27)
iOS AUv3 was historically "scaffolded but never actually loaded" — the
CMake helper and HostApp template both had multiple bugs that silently
prevented `AVAudioUnit.instantiate` from succeeding. The chain that has
to be right end-to-end:
1. **`.appex` binary type must be `MH_EXECUTE`, not `MH_BUNDLE`.**
`add_library(... MODULE ...)` produces `MH_BUNDLE`. PluginKit's
`posix_spawn` rejects bundles with **`ENOEXEC` ("Exec format
error")**, surfaced to the host as **OSStatus 4** from
`AVAudioUnit.instantiate`. Fix in `tools/cmake/PulpAuv3.cmake`
`_pulp_add_auv3_ios`:
```cmake
add_executable(${target}_AUv3 ...)
target_link_options(${target}_AUv3 PRIVATE
"-e" "_NSExtensionMain" "-fapplication-extension")
set_target_properties(${target}_AUv3 PROPERTIES
XCODE_PRODUCT_TYPE "com.apple.product-type.app-extension"
XCODE_ATTRIBUTE_WRAPPER_EXTENSION "appex"
BUNDLE TRUE BUNDLE_EXTENSION "appex"
RUNTIME_OUTPUT_DIRECTORY "...")
```
Verify with `file <appex>/<exec>` — must say `Mach-O 64-bit executable`,
NOT `Mach-O 64-bit bundle`.
2. **HostApp must use `.loadOutOfProcess` on iOS.** The default
in-process load is unsupported for AUv3 extensions on iOS;
`AVAudioUnit.instantiate(with: desc, options: [])` returns OSStatus 4.
Use `.loadOutOfProcess` (Apple's "Incorporating Audio Effects and
Instruments" sample documents this in a comment).
3. **HostApp's `AudioComponentDescription` filter must match the
extension exactly.** The shipped template literally filtered for
`kAudioUnitType_Effect` + subtype `Pu_E` — would never find any
instrument plug-in. Plug-in authors copying the template must update
the four-CC values to match their own AUv3's Info.plist
`AudioComponents` entry. Better fix: derive these from
`AVAudioUnitComponentManager.components(matching:)` against a
permissive description.
4. **Embedded `.appex` bundle ID must be a child of the HostApp's bundle
ID.** Apple enforces parent-child: extension bundle ID must START
with the containing app's bundle ID + `.` + suffix. Otherwise install
fails with **"Mismatched bundle IDs"**. The Pulp helper currently
derives the `.appex` bundle ID from the AUv3 target's `BUNDLE_ID` —
plug-in authors must set that arg to a child of the HostApp's
bundle ID, not to a sibling.
5. **HostApp entitlements containing `com.apple.security.application-groups`
require an explicit (non-wildcard) App ID with App Groups capability
enabled in Apple Developer**. Wildcard App IDs cannot use App Groups.
For pure plug-in development testing, strip the entitlement.
6. **Instruments (aumu) need MIDI to make sound.** Discovery + load is
not enough; the host must call `audioUnit.scheduleMIDIEventBlock`
with a `noteOn` byte sequence (`0x90, <key>, <vel>`). Apple's
`SimplePlayEngine.InstrumentPlayer` is the reference. Without this,
`engine.start()` succeeds but the synth sits silently waiting for
MIDI input.
7. **Simulator PluginKit caches stale registrations between launches.**
After a successful `INSTANTIATE_OK` once, a `terminate + relaunch`
without `uninstall + install` may flip to `INSTANTIATE_ERROR Code=4`
because PluginKit's database points at the old install UUID. **Real
device audio validation is authoritative; Sim is for build/discovery
smoke only.**
8. **iOS device requires `inter-app-audio` entitlement on the HostApp**
for `AVAudioUnitComponentManager.components(matching:)` to enumerate
AUv3 extensions on iOS 11+. Without it, the manager returns an empty
match list and your AUv3 appears invisible — even though `pkd`
indexed it correctly and `xcrun devicectl device install app`
succeeded. The iOS Simulator does NOT enforce this, so the missing
entitlement is silent until you try a real device. Fix in two places:
(a) one-time portal: enable Inter-App Audio on your wildcard
`com.<you>.pulpdev.*` App ID; Xcode auto-fetches the regenerated
profile on next build, (b) entitlements file: the shipped
`templates/ios-auv3/HostApp/Entitlements.plist.in` already includes
`<key>inter-app-audio</key><true/>`. Verify with
`codesign -d --entitlements :- HostApp.app | plutil -p -` →
`"inter-app-audio" => 1`. Apple deprecated IAA in iOS 13 (no new
IAA-only plug-ins on the Store) but the entitlement still gates
AUv3 host scanning — do not strip it. Full setup in
`docs/guides/ios-dev-signing.md`.
### iOS AUv3 diagnostic recipe
When the HostApp shows "(no AUv3 found)" or instantiate fails silently:
```swift
// Drop these prints into ContentView.discover():
let components = AVAudioUnitComponentManager.shared().components(matching: desc)
print("PULP_DISCOVER: matching=\(components.count) type=\(...) sub=\(...) mfr=\(...)")
let all = AVAudioUnitComponentManager.shared().components(matching: AudioComponentDescription())
print("PULP_DISCOVER_ALL: \(all.count) total")
for c in all where c.manufacturerName == "Pulp" { print("PULP_DISCOVER_ALL_PULP: \(c.name)") }
AVAudioUnit.instantiate(with: desc, options: .loadOutOfProcess) { node, error in
if let e = error { print("PULP_INSTANTIATE_ERROR: \(e)") }
guard let node = node else { return }
print("PULP_INSTANTIATE_OK: \(node.auAudioUnit.componentName ?? "?")")
}
```
Then launch via XcodeBuildMCP `launch_app_sim` (returns `runtimeLogPath`
capturing stdout); `grep PULP_ <runtimeLogPath>` shows the chain.
If `matching=0` → check #3 (descriptor mismatch).
If `matching=N` but `INSTANTIATE_ERROR Code=4` →
- Check #1 (`file <appex>/<exec>` says `bundle` not `executable`).
- Check #2 (`options: []` instead of `.loadOutOfProcess`).
- Check spawn errors: `xcrun simctl spawn booted log show --last 30s
--predicate 'eventMessage CONTAINS "PulpSineSynth" AND (eventMessage
CONTAINS "Exec format" OR eventMessage CONTAINS "posix_spawn")'`.
### iOS AUv3 audio validation
Simulator does NOT capture audio in `simctl io booted recordVideo`
(video-only). For audio verification you need either:
- Real device + headphones / mic capture
- Sim audio loopback via a Mac audio routing tool (BlackHole, Loopback)
- Or accept that "PULP_INSTANTIATE_OK + PULP_NOTE: ON + engine.start
succeeded" proves the wiring; trust the synth code path that's
already tested at the unit level
### Two CMake entry points: keep signatures in lockstep
`pulp_add_plugin(...)` (the general entry) and `pulp_add_ios_auv3(...)`
(the iOS-extension wrapper) both end up calling the internal
`_pulp_add_auv3(target name bundle_id version manufacturer category
plugin_code manufacturer_code accepts_midi)` helper with positional
arguments. When you add or remove an arg on `_pulp_add_auv3`, you
must update BOTH wrappers — a missed update on the iOS wrapper
surfaces as:
```
CMake Error at tools/cmake/PulpUtils.cmake:<line> (_pulp_add_auv3):
_pulp_add_auv3 Function invoked with incorrect arguments
```
only on the iOS toolchain configure, because the other leg
(`pulp_add_plugin`) never exercises the wrapper. Caught on CI's
Coverage-macOS leg in PR #638 when `ACCEPTS_MIDI` was added to
`_pulp_add_auv3` but not threaded through `pulp_add_ios_auv3`.
## macOS AU v3 packaging — framework + stub .appex + container .app (Phase 3.5)
**Apple's architecture, not a Pulp invention. Get this wrong and you
will spend an entire session debugging silent Pluginkit rejections.**
Apple's "Creating custom audio effects" sample doc states verbatim:
> "Your extension's main binary cannot be dynamically loaded into
> another app, which means all executable AU code must reside in a
> separate framework bundle. The extension target still needs to
> contain at least one source file for the extension binary to be
> created, properly loaded, and linked with the framework bundle."
iPlug2 ships the same 3-tier architecture. Pulp's macOS AU v3 lane
(`tools/cmake/PulpAuv3.cmake`'s `_pulp_add_auv3_macos_*` helpers,
Phase 3.5) follows this pattern exactly:
```
ChainerSynth.app/ ← container .app
├── Contents/
│ ├── MacOS/ChainerSynth ← tiny Cocoa shell, launched once to register
│ ├── PlugIns/
│ │ └── ChainerSynth.appex/ ← stub .appex (NSExtensionMain entry)
│ │ └── Contents/
│ │ ├── Info.plist
│ │ │ • NSExtensionPointIdentifier = com.apple.AudioUnit-UI
│ │ │ • NSExtensionPrincipalClass = PulpAUMacViewController
│ │ │ • NSExtensionAttributes.AudioComponentBundle =
│ │ │ <bundle-id>.AUv3Framework ← MUST match framework's CFBundleIdentifier
│ │ └── MacOS/ChainerSynth ← ~50KB stub binary, links framework
│ └── Frameworks/
│ └── ChainerSynthAUv3Framework.framework/ ← REAL code lives here
│ ├── Info.plist (CFBundlePackageType=FMWK)
│ └── Versions/A/
│ ├── ChainerSynthAUv3Framework ← contains PulpAudioUnit + PulpAUMacViewController
│ └── libwgpu_native.dylib ← any embedded dylibs
```
**iOS is different — iOS AU v3 still uses the monolithic .appex.** The
framework split is macOS-specific because of Apple's `loadInProcess`
out-of-process requirement on macOS. `pulp_add_ios_auv3()` stays on the
legacy monolithic path; `pulp_add_plugin(FORMATS AUv3)` dispatches to
the macOS framework path on macOS.
### What goes in the framework vs the .appex stub
- **Framework** (`_pulp_add_auv3_macos_framework`): the per-plugin
Core OBJECT lib + `au_adapter.mm` (PulpAudioUnit) +
`au_view_controller_mac.mm` (PulpAUMacViewController +
AUAudioUnitFactory) + per-plugin `au_v3_entry.cpp` (the
`PULP_AUV3_PLUGIN` macro that registers the processor factory).
- **Stub .appex** (`_pulp_add_auv3_macos_appex`): a generated 1-function
`.mm` source — `void Pulp_<plugin>_AUv3_keep_alive(void)`. Entry
point is Apple-provided `_NSExtensionMain`; we pass
`-e _NSExtensionMain` and `-fapplication-extension`. The stub links
the framework with `-Wl,-force_load,$<TARGET_FILE:framework>` so its
Obj-C classes register with the runtime — without `-force_load`,
`NSClassFromString(@"PulpAUMacViewController")` returns nil and the
host fails to instantiate the AU.
- **Container .app** (`_pulp_add_auv3_macos_host`): tiny Cocoa shell
with a "this is the registration host" placeholder window. Bundle ID
`<plugin-bundle-id>.AUv3Host`. The user runs it once after install
to trigger Launch Services scan.
**Do NOT put `au_entry.mm`'s `PulpAUFactoryObj` (legacy
AudioComponentRegister factory C function) anywhere in the macOS AU v3
lane.** The macOS path uses `_NSExtensionMain` + `NSExtensionPrincipalClass`
to find the factory class. iPlug2 follows the same convention.
### rpath: 4 levels up, not 2
The .appex's binary at
`MyApp.app/Contents/PlugIns/MyApp.appex/Contents/MacOS/MyApp` needs to
find the framework at `MyApp.app/Contents/Frameworks/`. From the
binary, that's **4 parent dirs up** (`MacOS → Contents → MyApp.appex →
PlugIns → Contents → Frameworks`):
```cmake
set_target_properties(${appex_target} PROPERTIES
INSTALL_RPATH "@executable_path/../../../../Frameworks")
```
**iPlug2's modern CMake helper has this wrong** — it sets
`@executable_path/../../Frameworks` which works for iOS's flat .appex
layout but breaks macOS where the .appex has its own
`Contents/MacOS/`. Don't copy that recipe.
The container .app's binary at `MyApp.app/Contents/MacOS/MyApp` needs
2 parent dirs up: `INSTALL_RPATH "@executable_path/../Frameworks"`.
### Signing + notarization is mandatory on Sequoia/Tahoe
macOS Tahoe's Pluginkit silently rejects ad-hoc-signed, Developer-ID-
signed-without-notarization, and even properly Developer-ID-signed but
unnotarized AU v3 .appex bundles. `pluginkit -mAvvv -p com.apple.AudioUnit-UI`
returns "no matches" with zero log diagnostics. The only signal you
get is the absence of the plugin.
You MUST:
1. **Sign embedded dylibs first** (`libwgpu_native.dylib`, etc.) with
`--timestamp --options runtime` and the same Developer ID identity
2. **Sign the framework**
3. **Sign the .appex** with `--entitlements <sandbox>.plist` (the
`com.apple.security.app-sandbox` entitlement is REQUIRED for app
extensions; without it pkd logs "plug-ins must be sandboxed" and
rejects). Plus `allow-jit` + `allow-unsigned-executable-memory` +
`disable-library-validation` for JS-engine + Skia/Dawn editors.
4. **Sign the container .app** (also with hardened-runtime entitlements
for library validation)
5. **Notarize the container .app** via `xcrun notarytool submit
--apple-id <id> --team-id <team> --password <app-specific-pwd>
--wait`
6. **Staple** with `xcrun stapler staple`
7. **Install to /Applications** and `lsregister -f -R`
8. **Open the container .app once** to trigger Launch Services scan
→ Pluginkit then registers the embedded extension
The full recipe is in `tools/scripts/sign-notarize-auv3-mac.sh`.
For the reusable dev-signing cred layout that step 5 consumes
(`PULP_TEAM_ID` / `PULP_NOTARY_*`), see
[`docs/guides/ios-dev-signing.md`](../../../docs/guides/ios-dev-signing.md) —
schema template + sourceable helper, no per-user identifiers in
committed code.
**Diagnostic for silent Pluginkit rejection:**
```bash
# Should return the plugin's bundle ID + path
pluginkit -mAvvv -p com.apple.AudioUnit-UI | grep <your-plugin>
# Should be registered as an AU component
auval -a | grep <your-fourcc>
# Should pass FORMAT + RENDER tests (validates the AU loads + processes
# audio in AUHostingServiceXPC out-of-process)
auval -v aumu <subtype> <manufacturer>
```
**Audio etiquette**: `auval -v` plays test tones / noise through the host's
default audio device while running RENDER. Announce before invoking
(per CLAUDE.md → *Local-dev audio etiquette*) and prefer the shortest
necessary validation when the user may be listening to something else.
Tracked as issue [#3173](https://github.com/danielraffel/pulp/issues/3173).
**auval does NOT exercise the AU v3 controller path** — auval calls
`AudioComponentInstantiate` directly, bypassing the
`AUAudioUnitFactory` lifecycle that hosts use via XPC. Threading bugs
in `createAudioUnitWithComponentDescription:error:` /
`PulpAUMacViewController` will pass auval and crash inside Logic /
Reaper / Ableton. A proper integration test needs an XPC client that
calls `requestViewControllerWithCompletionHandler` — Apple's AUv3Host
sample is the template.
### CMake POST_BUILD embed step doesn't re-fire on framework-only edits
Without a sentinel, `add_custom_command(TARGET host POST_BUILD ... cp
framework into app)` only runs when the host target itself relinks.
A framework-only source edit (e.g. tweaking
`au_view_controller_mac.mm`) won't relink the host, so the embedded
framework in the .app stays stale. You sign + notarize the OLD binary
while thinking you're testing the new one — symptom: the same
crash repeats with the same byte offset after every "rebuild".
`PulpAuv3.cmake` fixes this with a stamp-file `add_custom_command` +
`add_custom_target(${host}_Embed ALL DEPENDS stamp)`. The host's
output triggers the embed step whenever the framework or .appex
binary is newer than the stamp. Don't revert to plain POST_BUILD.
`tools/scripts/sign-notarize-auv3-mac.sh` also re-syncs the embed at
sign time as a belt-and-suspenders.
### Threading: `createAudioUnit:error:` runs on the XPC queue, NOT main
The host (Logic / Reaper / Ableton / AUM) invokes
`-[PulpAUMacViewController createAudioUnitWithComponentDescription:error:]`
on the `com.apple.NSXPCConnection.user.endpoint` serial queue, not the
main thread. Any AppKit/UIKit call from there throws
`NSInternalInconsistencyException` (`setPreferredContentSize:`,
`self.view`, the `PluginViewHost::attach_to_parent` AppKit attach).
The thrown exception kills the .appex process and Logic reports
"Failed to load Audio Unit".
The fix in `au_view_controller_mac.mm` is a HARD GUARD at the top of
`rebuildEditorIfReady`:
```objc
- (void)rebuildEditorIfReady {
if (![NSThread isMainThread]) {
dispatch_async(dispatch_get_main_queue(), ^{
[self rebuildEditorIfReady];
});
return;
}
// ... AppKit work
}
```
**Don't just guard at `setAudioUnit:` (the property setter).** The
compiler can inline through the property setter when `createAudioUnit`
assigns to `self.audioUnit`, bypassing your thread check. The hard
guard inside `rebuildEditorIfReady` is the only safe place. Same
gotcha on iOS — `au_view_controller_ios.mm` has the same guard.
### Logic's per-plugin failed-state cache
Logic Pro remembers AU v3 plugins that previously failed to validate
in `~/Library/Preferences/com.apple.logic10.plist` under
`audioUnitConfig.<type>-<subtype>-<manufacturer>`. Working entries are
populated dicts; failed entries are `<dict/>` (empty). Logic will
**not** re-attempt loading an empty-dict entry on relaunch — even
after you've fixed the bug and reinstalled, Logic refuses to list the
plugin until you delete that entry.
Recovery without a full AU rescan:
```bash
# Logic Pro must be QUIT first
killall -9 cfprefsd
# Edit on disk while cfprefsd is dead so it reads fresh on next access
plutil -convert xml1 -o /tmp/logic10.xml ~/Library/Preferences/com.apple.logic10.plist
sed -i.bak '/<key>aumu-Chnr-Pulp<\/key>/{N;d;}' /tmp/logic10.xml
plutil -convert binary1 -o ~/Library/Preferences/com.apple.logic10.plist /tmp/logic10.xml
killall -9 cfprefsd AudioComponentRegistrar pkd
rm -f ~/Library/Caches/AudioUnitCache/com.apple.audiounits.cache \
~/Library/Caches/AudioUnitCache/com.apple.audiounits.sandboxed.cache
# Now launch Logic — it'll incrementally rescan (NOT a full scan) and
# pick up the fresh registration from AudioComponentRegistrar.
```
`PlistBuddy` does NOT work for editing this — it talks to cfprefsd
which serves a cached in-memory view of the plist. Edit the XML
directly while cfprefsd is killed.
### Phase 3 / 3.5 view configuration plumbing
`PulpAUMacViewController` + `PulpAUViewController` implement
`AUAudioUnitFactory`, open `ViewBridge` against the AU's real
`pulpProcessor` + `pulpStore`, build `PluginViewHost` via
`decide_gpu_host`, and (on macOS) call `set_design_viewport(w, h)` +
`set_fixed_aspect_ratio(w/h)` so the editor paints at design size and
host-driven window resize is letterboxed proportionally. **iOS differs:**
`au_view_controller_ios.mm` deliberately does NOT force a design viewport —
it lays the root out at the actual pane bounds so a responsive flex scene
fills edge-to-edge (aspect-locked scaling left dark letterbox bars on the
sides and pushed header text to the edge). A fixed-aspect iOS editor that
needs letterboxing can call `set_design_viewport` itself; see the `ios` skill.
On macOS, the view controller's root view must be created at the
compile-time design size when `PULP_PLUGIN_DESIGN_W/H` are available.
REAPER can choose the initial AUv3 container from `loadView` /
`viewDidLoad` before `createAudioUnit` provides the processor; falling
back to `400x300` there opens imported/scripted UIs in a small padded
window even though the later `ViewBridge` reports the correct design
size.
REAPER can also shrink the controller view after the first editor build
on its in-process AUv3 path. The macOS controller has a one-shot initial
size sync after attaching `PluginViewHost`: if the first live layout is
smaller than the design viewport, it re-applies `preferredContentSize`,
expands the host window by the exact view delta, and resizes the root
view to the design. Keep this limited to initial attach; manual host
resize must continue through `viewDidLayout` without being forced back.
**Do NOT implement `supportedViewConfigurations:` / `selectViewConfiguration:`
for fixed-design editors.** (Verified in Logic 2026-05-29; replaced the earlier
"accept a large-enough config" policy, which *caused* the bug.) Logic Pro sizes
AU v3 editors through the view-configuration path and offers ONLY oversized ~4:3
configs (measured: 1024x768 / 1366x1024). The moment the AU returns *any*
supported config, **Logic locks the editor window to that config's aspect ratio
at every size** — so a wide fixed design (e.g. 900x520 ≈ 16:9.4) letterboxes with
top/bottom bars that *cannot be resized away* (confirmed by AX resize probing:
grow/shrink all snap back to 4:3). Apple's CoreAudioKit header states an empty
index set means "use the largest available view configuration," so returning
empty makes Logic pick its *largest* 4:3 config — strictly worse.
Base `AUAudioUnit` already implements these selectors. By NOT overriding them,
Logic falls back to the plain view at `preferredContentSize` and lets the window
**free-resize to the design's own aspect** — tight and proportional, matching
REAPER (in-process, honors design size), CLAP (`gui_get_size`), VST3 (`getSize`),
and standalone. REAPER never used these selectors, so removing them is a no-op
there. Regression-guarded by `test_au_plugin_state.mm` → "AU v3 does not opt into
host view configurations" (asserts PulpAudioUnit's IMP for both selectors equals
the inherited base IMP — i.e. not overridden).
If a future *fluid/multi-config* editor genuinely wants host view configurations,
reintroduce the selectors gated on that editor kind, but keep them OFF for any
`set_design_viewport` / `set_fixed_aspect_ratio` (fixed-design) editor.
### Logic OOP first-paint clip — defer GPU-host creation until a real size
After the view-config fix above, Logic stopped letterboxing but the editor's
**first paint** still clipped: the UI rendered at the design size inside Logic's
restored (smaller) window — top + right cut off — until a manual resize or a
window close+reopen snapped it tight. Reopen worked (the container already
existed at the right size); only the very first open raced.
Root cause (verified in Logic 2026-05-29): Logic hosts AU v3 **out-of-process**
and does **not** push its restored window size to the extension's view on
initial open — not via `viewDidLayout`, not via `setFrameSize:`, not via a
`self.view.bounds` change during a ~0.5s poll. It embeds our view, leaves it at
the design-size frame from `loadView`, and composites that oversized layer into
its smaller window. The GPU surface (created at attach with `opts.size = design`)
therefore paints once at design size and the stale frame persists until the host
next requests a redraw (a resize or reopen). Forcing a repaint, re-asserting
`preferredContentSize`, or forcing the host window size did **not** fix it (and
forcing Logic's window is host-hostile — it fights Logic's restore).
Fix (the one that worked) — **defer creating the `PluginViewHost` / GPU surface
until the root view reports a real, settled host size**, so the surface is never
born at the design size for a smaller first window. Mechanics in
`au_view_controller_mac.mm`:
1. `loadView` makes the root view a `PulpAUMacRootView` (NSView subclass)
overriding `setFrameSize:` → an `onResize` block; in `viewDidMoveToSuperview`
it fills its superview the **frame-based** way (`autoresizingMask` =
width|height-sizable + `frame = superview.bounds`) so AppKit sizes it to the
host container on embed and every host resize.
**⚠️ Do NOT use Auto Layout (`translatesAutoresizingMaskIntoConstraints=NO` +
edge constraints) to pin it — that CRASHED Ableton Live** (2026-05-29):
when the host places the AU window, our `setFrameSize:` → `[super]` →
`setNeedsLayout` engages the constraint engine (`-[NSWindow
_postWindowNeedsLayout]`), which throws in that context and the uncaught
exception kills the host. Frame-based autoresizing fills the container
identically for our purposes (the deferred GPU host reads the real bounds)
without touching the constraint engine, and is compatible with frame-driven
AU hosts (Live, REAPER, Logic). Lesson: in an AU editor view embedded by an
arbitrary host, never engage Auto Layout against the host's window.
2. `rebuildEditorIfReady` opens the `ViewBridge`, sets `preferredContentSize`,
wires `onResize`, sets `_viewHostPending` + `_pendingRoot`, and does **not**
create the host or force the view frame.
3. `-createViewHostIfReady` builds the host **at the view's real bounds**
(`opts.size = bounds`, not design), then `set_design_viewport(design)`.
While bounds still equal the design size it **waits** (up to the
`kInitialSizeSyncMaxAttempts` × `kInitialSizeSyncIntervalMs` settle window)
for the host's likely-different restored size; after that it accepts the
current size. It is driven from `onResize`, `viewDidLayout`, and the
`runInitialSizeSync` fallback (all idempotent).
4. Clear `onResize` first in `dealloc` (it captures `self` unretained + touches
`_viewHost`, which is destroyed after `[super dealloc]`).
Trade-off: a brief (<0.5s) black frame on first open while we wait for the real
size — acceptable, and far better than a clipped first paint. **Never create the
AU v3 GPU surface at the design size before the host has sized the view.** When
working on Dawn/Skia-backed editors in any out-of-process host, treat
"first-paint size" as a first-class concern: confirm what size the host actually
delivers and when, rather than assuming `viewDidLayout`/`preferredContentSize`
will be honored on initial open.
### REAPER (etc.) FX-chain letterbox — top-align the design viewport
A second AU sizing asymmetry vs CLAP/VST3: when a host gives the AU a pane
TALLER than the design aspect (e.g. REAPER's FX-chain pane — AU has no host
resize-negotiation like CLAP's `gui_adjust_size` / VST3's `checkSizeConstraint`,
so the host picks the pane aspect), the centered design floated between top+bottom
bands while CLAP/VST3 (aspect-constrained → no slack) sat content-at-top. Fix:
the AU controller calls `PluginViewHost::set_design_viewport_top_align(true)` →
`compute_design_viewport_transform(..., top_align=true)` anchors the design to the
TOP (slack becomes a single bottom strip), reading like CLAP/VST3. It is **only**
the AU path + only visible when there is vertical slack (no-op when the pane is
design-aspect), and the SAME transform feeds paint AND input mapping
(`window_to_root_point`) so clicks stay aligned. True pixel-fill parity is not
achievable (AU can't negotiate the pane aspect). Verified in REAPER 2026-05-30.
### The `PULP_AUV3_PLUGIN()` macro replaces hardcoded force_link
Pre-Phase 3.5, `au_entry.mm` called `pulp_gain_force_link()` to force
the linker to retain pulp-gain-specific static initializers. This
broke AU v3 for every plugin OTHER than pulp-gain. Phase 3.5 ships
`<pulp/format/au_v3_entry.hpp>` with `PULP_AUV3_PLUGIN(factory_fn)`
— place it in ONE `.cpp` per plugin (convention: `au_v3_entry.cpp`
in the plugin's source dir). The CMake helper auto-discovers and
links it into the framework. Mirrors `PULP_CLAP_PLUGIN` and
`PULP_AU_INSTRUMENT`.
## Gotchas
### `AURenderEventMIDIEventList` = UMP — not short MIDI, not raw sysex
AU v3.1+ delivers long MIDI and MIDI 2.0 messages through
`AURenderEventMIDIEventList`, which carries a `MIDIEventList` of
`MIDIEventPacket` structs — **UMP-encoded** 32-bit words. Sysex7
arrives as type-3 UMP messages spread across 2-word packets with a
4-bit status field in bits 20–23 of word 0:
```
status == 0x0 → complete single-packet sysex
status == 0x1 → start (reset accumulator)
status == 0x2 → continue
status == 0x3 → end
```
As of macOS plan item 8.2, the sysex7 reassembly state machine no
longer lives inline in `au_adapter.mm` — it now delegates to the
shared `pulp::midi::UmpSysex7Reassembler`
(`core/midi/include/pulp/midi/ump_sysex7_reassembler.hpp`) so the
same battle-tested implementation backs every UMP-aware Pulp backend
(AUv3, CoreMIDI device input, and any future Win/Linux UMP path).
`au_adapter.mm` only owns the AURenderEventMIDIEventList walk, the
word-cursor advance, and the per-MIDIEventList `EmitCtx` that tags
the assembled sysex with `event->head.eventSampleTime`.
When touching the AUv3 sysex path: prefer fixes inside the shared
reassembler (and `test/test_ump_sysex7_reassembler.cpp`) over
adapter-local patches. Two P1 invariants the adapter still owns
itself remain unchanged and important:
1. **Advance the word cursor by `ump_words`, not by 1.** A type-3
message is 2 UMP words long; advancing by 1 makes the second
word's header nibble look like a new message header (`#292` P1).
This lives in the `switch (mt)` block above the call to
`reassembler.feed_packet`.
2. **`reassembler.feed_packet` expects an already-type-3 packet** —
the adapter checks `mt == 0x3` before calling. Don't push the
type check into the reassembler; both call sites already need the
nibble for cursor advance and re-checking would be redundant in
the hot path.
Sysex7 size is still 0..6 bytes per 2-word packet (#292 P2 —
preserve message boundaries); the reassembler clamps to 6
defensively.
Both invariants are tested by
`test/test_ump_sysex7_reassembler.cpp` (the `#292 P1` test feeds a
contrived packet whose word1 begins with a nibble matching sysex7
to prove word1 is never reparsed as a fresh word0). Touch the
reassembler → add a test that exercises the boundary.
### Short-MIDI length must be validated
`AURenderEventMIDI.length` is the length in bytes. Short messages are
1..3 bytes and `data[0]`'s MSB must be set (status byte). The adapter
explicitly rejects `length == 0`, `length > 3`, and messages with
`(data[0] & 0x80) == 0`. Do not relax that gate — corrupt short
messages past the gate feed `choc::midi::ShortMessage` garbage.
### `_bridge` captured as raw pointer in the render block
The render block captures `&_bridge` (a C++ struct inside the Obj-C
class) as a raw pointer. ARC does not retain `_bridge`. Keeping the
audio unit alive is the host's job; the block lives for the audio
unit's lifetime. **Do not** capture `self` into the render block —
that creates a retain cycle that only breaks when the host drops the
unit, and Logic will reproduce-steps that via preset hot-swap.
The MIDI-out fan-out in the same block does capture `self.MIDIOutputEventBlock`
via ARC (`__block id` style through the implicit-self path). That one
is intentional — the block the host installs is ARC-retained on the
audio unit and does not form a cycle.
### `allocateRenderResourcesAndReturnError` is where `prepare()` lives
Not in `initWithComponentDescription:`. The host may instantiate the
audio unit to enumerate parameters / buses without ever rendering;
calling `Processor::prepare()` before the host has a sample rate +
max frames in hand wastes work and can mis-size buffers. Mirror:
`deallocateRenderResources` calls `processor->release()`.
### `tailTime` is in **seconds**, not samples
Pulp's `descriptor().tail_samples` is an integer sample count;
`tailTime` returns seconds. `< 0` means infinite and returns
`std::numeric_limits<double>::infinity()` (AU's sentinel). Do not
return `0` — a `0` tail tells the host "this plugin emits nothing
after input stops" and delay/reverb tails get chopped.
### Bypass routing — auto-detected Bypass parameter (PR #2937)
`initialize` auto-detects a plugin-declared "Bypass" parameter and
routes both AU v3 bypass surfaces (the host's `bypass` AUValue and
the plugin's automation lane) through the **same StateStore atomic**
so they stay in lockstep (DAW quirks row 21). When no Bypass param
exists the bridge falls back to a local atomic so the contract still
holds for plugins that don't declare one.
`internalRenderBlock` short-circuits to pass-through audio when
bypassed (in→out for effects, silence for instruments) and never
calls `Processor::process`. **MIDI output stays empty** so bypassed
MIDI FX don't leak notes. Diagnostic: read `pulpBypassParameterId`
on `PulpAudioUnit` (also exposed from the shared `au_audio_unit.h`
header) to confirm which ParamID got picked up.
### Latency / tail change notifications (PR #2934, item 3.11)
A Processor flags a mid-render latency or tail change via
`flag_latency_changed()` / `flag_tail_changed()` (RT-safe atomic
store-release). The adapter drains those edges post-process and
`dispatch_async`s to the main queue → KVO `willChange/didChange` for
`latency` / `tailTime`. The file is built **without ARC** because of
the C++ `_bridge` struct, so the dispatch path uses MRC-safe
retain/release rather than ARC capture semantics. Tests are in
`pulp-test-processor-layout-latency` (round-trip × 2, two-thread
hammer for data-race freedom).
### Sidechain pull uses its **own** `AudioBufferList`
Aliasing the main `input_abl` into the sidechain pull corrupts the
main input (the pull overwrites it). `sidechain_abl` +
`sidechain_storage` are separate by design — the storage is sized for
`kMaxChannels * max_frames` at `allocate`, with a defensive re-size
inside the render block for the rare case where a host asks for more
frames than `maximumFramesToRender` claimed.
### Factory entry point: use `PULP_AUV3_PLUGIN()`, NOT a hand-rolled force_link
**Pre-Phase 3.5 (removed):** `au_entry.mm` called `pulp_gain_force_link()`
to force-retain pulp-gain's `au_register.cpp` static initializers.
That symbol was hardcoded to pulp-gain and broke AU v3 for every
other plugin.
**Phase 3.5+:** every plugin includes a per-plugin `au_v3_entry.cpp`
in its source dir with:
```cpp
#include "my_plugin.hpp"
#include <pulp/format/au_v3_entry.hpp>
PULP_AUV3_PLUGIN(my_namespace::create_my_plugin)
```
`PulpAuv3.cmake` auto-discovers this file (by path convention) and
links it into the AU v3 framework (macOS) or .appex (iOS). The
macro expands to `PULP_REGISTER_PLUGIN`, which puts a static
initializer in the TU; the linker keeps the file because CMake's
OBJECT lib + framework SHARED lib both reference its symbols.
Mirrors `PULP_CLAP_PLUGIN` and `PULP_AU_INSTRUMENT`.
### Channel count hard limit of 8
`kMaxChannels = 8`. Bumping that requires re-sizing every
pre-allocated buffer array and validating hosts don't ask for more
channels than the descriptor declares. Not a surround-readiness flag
yet.
### AU v3 native view plumbing (Phase 3.5)
AU v3 uses `requestViewControllerWithCompletionHandler:` to fetch an
`AUViewController`. macOS uses `PulpAUMacViewController` (in the
framework, in macOS AU v3); iOS uses `PulpAUViewController` (in the
monolithic .appex). Both implement `AUAudioUnitFactory` so the same
class is both the factory and the view-providing controller — Apple's
recommended pattern.
`au_v2_cocoa_view.mm` (the AU v2 Cocoa view path) remains the editor
mechanism for the AU v2 `.component` bundle. AU v3 has its own,
parallel view path via the principal class.
Cross-platform editor wiring (ViewBridge, PluginViewHost, design
viewport, GPU host selection) is shared between both AU v3
controllers — see the `view-bridge` skill.
### `auval` is the AU gate
`auval` ships with macOS; `pulp validate` wraps the CTest target
`auval-<name>` rather than running `auval` directly. On a raw
development machine, run manually via e.g.
`auval -v aufx MyPl Plup`. A freshly built `.component` that was just
copied into `~/Library/Audio/Plug-Ins/Components/` requires a cached-
plist rebuild — delete
`~/Library/Caches/AudioUnitCache/` and `~/Library/Caches/com.apple.audiounits.cache`
(or call `killall -9 AudioComponentRegistrar`) before validating a new
bundle.
### iOS extension principal class is declared in Info.plist
AUv3 iOS extensions use `NSExtensionPrincipalClass` =
`PulpAUViewController` in the extension target's Info.plist, not
`NSExtensionMain`. If the extension fails to load in a host (Cubasis /
AUM), check the Info.plist before the Obj-C — a typo in the principal
class name fails silently.
### `PulpAUViewController::dealloc` — never call `_bridge->close()` explicitly
The view controller declares its ivars `_bridge` (ViewBridge), then
`_fallbackView` (View), then `_viewHost` (PluginViewHost). When `[super
dealloc]` runs, the runtime destroys C++-typed ivars in REVERSE
declaration order: `_viewHost`, `_fallbackView`, `_bridge`. That
ordering is load-bearing:
1. `~PluginViewHost` runs FIRST. It calls
`root_.set_plugin_view_host(nullptr)` (and `set_frame_clock(nullptr)`
on the GPU host). `root_` references either `_bridge->view_` OR
`_fallbackView` — BOTH are still alive at this point, so clearing
the back-pointers is safe on either path.
2. `~View` (`_fallbackView`) runs next — no-op on the bridge-success
path; on the fallback path the back-pointer was cleared in step 1.
3. `~ViewBridge` runs last. Its destructor calls `close()` →
`Processor::on_view_closed` → `view_.reset()`. The back-pointer was
already cleared in step 1.
**`_viewHost` MUST be declared last (destroy first).** The original order
`_bridge, _viewHost, _fallbackView` destroyed `_fallbackView` before
`_viewHost`; on the no-`audioUnit` preview path `_fallbackView` *is* the
View `_viewHost->root_` references, so the host cleared a back-pointer into
a freed View (GPU-plugin-view-host work, 2026-05; Codex P1).
Calling `_bridge->close()` HERE explicitly (before `[super dealloc]`)
reverses that order: the View dies first, then `~PluginViewHost`
dereferences a dangling `root_` reference and crashes AUv3 editor
close. An earlier variant explicitly closed the bridge here and shipped
that crash; the fix is to remove the explicit close, NOT to add it.
The AUv3 editor now also auto-selects the GPU host via the shared
`decide_gpu_host()` helper (Options overload) — see the `view-bridge`
skill's "GPU view host auto-selection" section.
### Headless automation must not create fallback AUv3 UI
When `PULP_DISABLE_PLUGIN_EDITOR`, `PULP_HEADLESS`, `PULP_TEST_MODE`,
or `CI` is set, `PulpAUViewController` returns after setting its basic
view state and does not build `ViewBridge`, `PluginViewHost`, or the
fallback empty view. The fallback is only for preview/no-audioUnit cases;
do not use it to satisfy a test/CI launch because it still creates a
native host surface.
## Validation recipes
Build and validate via the Pulp CLI:
```bash
./build/pulp build
./build/pulp validate # runs auval via the auval-<name> CTest target
```
Manual `auval` (macOS only — `auval` is an Apple tool):
```bash
# List all registered AUs; find yours in the list
auval -a
# Validate an effect (type/subtype/manufacturer are 4-char codes)
auval -v aufx MyPl Plup
# Validate an instrument
auval -v aumu MySy Plup
```
If `auval -a` doesn't list the plugin, the AU cache is stale. Reset
it:
```bash
killall -9 AudioComponentRegistrar
rm -rf ~/Library/Caches/AudioUnitCache/ \
~/Library/Caches/com.apple.audiounits.cache
```
`auval -r` runs the longer reinit-stress pass; use it before shipping
a release but not on every iteration — it takes minutes.
iOS: no standalone `auval`-equivalent. Run the AUv3 extension in the
AUHost sample app (available from Apple's developer portal) or inside
AUM / Cubasis to smoke-test instantiation + render. See the `ios`
skill for device deploy.
## Packaging — macOS appex + iOS device + Simulator (item 3.10)
The AU v3 packaging shape is **three distinct targets**, dispatched by
`_pulp_add_auv3` in `tools/cmake/PulpAuv3.cmake`:
1. **macOS** — framework-inside-containing-app:
`${target}_AUv3Framework` (SHARED FRAMEWORK with the AU code),
`${target}_AUv3` (stub `.appex` linking the framework via
`AudioComponentBundle`), `${target}_AUv3Host` (containing `.app`
with both embedded under `Contents/Frameworks` + `Contents/PlugIns`).
2. **iOS device** — single monolithic `.appex` produced by
`_pulp_add_auv3_ios`; signed with the
`templates/auv3/iOS-Device-Entitlements.plist.template` entitlements
(application-groups).
3. **iOS Simulator** — same `_pulp_add_auv3_ios` path, but configure
picks `iOS-Simulator-Entitlements.plist.template` instead. CMake
detects the Simulator via `CMAKE_OSX_SYSROOT` matching
`Simulator|iphonesimulator`. Mac Catalyst is **deliberately
excluded** (macOS plan 3.10 — "Catalyst is deferred post-MVP").
### Xcode-project generation: `pulp ship auv3-xcodeproj` (PR #2938, item 3.10)
Once `pulp_add_plugin(... FORMATS AUv3)` is wired, the developer
flow for iterating on the AUv3 target in Xcode (instruments,
debugger, simulator profiles) is:
```bash
pulp ship auv3-xcodeproj <target> # iphonesimulator (default)
pulp ship auv3-xcodeproj <target> --sdk iphoneos # device
pulp ship auv3-xcodeproj <target> --sdk macosx # macOS lane
pulp ship auv3-xcodeproj <target> --output build/xcode/MyPlugin
pulp ship auv3-xcodeproj <target> --open # open in Xcode after gen
pulp ship auv3-xcodeproj <target> --dry-run # print cmake invocation only
```
The wrapper runs `cmake -G Xcode -DPULP_AUV3_TARGET=<name>` against
a **separate build dir** (default `build/xcode/<target>-<sdk>`) so
it doesn't collide with the user's normal Ninja/Makefile cache. iOS
SDKs pull in `tools/cmake/ios.toolchain.cmake` with the correct
`IOS_PLATFORM` (OS for device, SIMULATOR64 for simulator). This is
the closeout for item 3.10's deferred Xcode-project generation —
the entitlement templates landed earlier in the same PR series.
### Install + cache-clear gotcha
`pulp-install-${target}` for AUv3 copies the **containing `.app`** to
`~/Applications/<name>.app`, then runs:
```
/usr/bin/pluginkit -a "<app>/Contents/PlugIns/<name>.appex"
/usr/bin/killall -9 AudioComponentRegistrar # may be a no-op if it isn't running
```
The `pluginkit -a` registration is what makes Launch Services + the AU
host's `AVAudioUnitComponentManager` discover the extension on next
relaunch. The `killall` step flushes the AudioComponent cache so the
DAW sees the new component without a full logout. Both steps are
documented in `pulp doctor --au-cache`; the install target wires them
automatically.
`~/Library/Audio/Plug-Ins/Components/` is **AU v2 only** — AU v3 hosts
discover extensions through PlugInKit, not the v2 component directory.
Don't try to install an AU v3 `.appex` there.
## Cross-references
- `.agents/skills/ios/SKILL.md` — iOS extension wiring, simulator
deploy, audio session handling.
- `.agents/skills/view-bridge/SKILL.md` — editor contract. On iOS,
`au_view_controller_ios.mm` is the canonical AUv3 example of the
protocol.
- `.agents/skills/auv2/SKILL.md` — the AU v2 adapter, separate bundle.
- `.agents/skills/ara/SKILL.md` — `audioUnitARAFactory` KVO property.
- `.agents/skills/mpe/SKILL.md` — MPE sidecar contract (AU v3 delivers
MPE as short MIDI via `AURenderEventMIDI`; the Pulp path is the same
`MpeVoiceTracker` as CLAP / VST3).
- `.agents/skills/clap/SKILL.md` and `.agents/skills/vst3/SKILL.md` —
cross-format parity sanity-check for host-specific regressions.
### iOS AUv3 controller hands GpuSurface to ScriptedUiSession (Phase iOS-D.3b Slice 1)
`au_view_controller_ios.mm` and `au_view_controller_mac.mm` both now
call, immediately after `PluginViewHost::create()`:
```cpp
if (auto* scripted = _bridge->scripted_ui()) {
scripted->attach_gpu_surface(_viewHost->gpu_surface());
}
```
This routes the JS-side `navigator.gpu` / `canvas.getContext('webgpu')`
shim through the host's live Dawn surface. Skip it and any embedded
WebGPU JS content (Three.js, raw WebGPU) renders black with no error —
the shim silently falls through to mocks. Verify with the log line
`[plugin-gpu-host] GpuSurface attached to WidgetBridge via
ScriptedUiSession (iOS AUv3)`.
Full cross-platform contract lives in the `view-bridge` skill's
"GpuSurface plumbing into WidgetBridge" section.
- `docs/guides/ios-auv3-guidance.md` — the human-facing iOS AUv3 guide.
- `docs/guides/formats.md` — user-facing format overview + auval
recipes.
- Memory note: AAX-parity sweep — AU sysex (#288), VST3 sysex (#274),
CLAP sysex (#269) and AAX sysex (#239) all share the same
sidecar. Fixing one means checking the other three.
## Host-quirks consumption (P3a, 2026-05-30)
This adapter consumes the host-quirks ledger at init: it caches
`resolved_quirks(detect_host_info().type, version)` once (the runtime
policy — `PULP_HOST_QUIRKS` env / `set_host_quirk_policy()` API / compile
default — applies via `resolved_quirks()`), then gates DAW accommodations
on those flags instead of hardcoding them.
First wired flag: `clamp_latency_to_nonneg`. Latency reporting routes
through the pure helper `pulp::format::reported_latency_samples(raw, quirks)`
(in `host_quirks.hpp`): a negative `latency_samples()` clamps to 0 when the
quirk is enforced, and passes through raw (wrapping the unsigned host field)
when `PULP_HOST_QUIRKS=off`. See `docs/reference/host-quirks-policy.md`.
**Obj-C gotcha:** in `au_adapter.mm` the `@implementation` method bodies are
at *file scope*, NOT inside `namespace pulp::format::au`, so unqualified
lookup of namespace free functions fails to compile. Qualify them:
`pulp::format::detect_host_info()`, `pulp::format::resolved_quirks(...)`,
`pulp::format::reported_latency_samples(...)`. (Struct members like the
cached `HostQuirks host_quirks` resolve fine — the struct is in-namespace.)
The core lib doesn't compile this `.mm`, so only the AU target/test catches
such errors — build `pulp-test-au-plugin-state`.
## synthesize_bypass_parameter (host-quirks P3b, 2026-05-30)
When the plugin declares no Bypass parameter and the quirk is enforced,
the adapter calls `pulp::format::maybe_synthesize_bypass(store, quirks)`
(in `quirk_apply.hpp`) right after `define_parameters` — injecting an
automatable boolean `"Bypass"` param with the reserved ID
`kSynthesizedBypassParamId` (0x70427970). The adapter's EXISTING bypass
detection (name == "Bypass", boolean range) then adopts it, so the
pass-through short-circuit honors it with no further wiring.
`PULP_HOST_QUIRKS=off` synthesizes nothing. Existing "no-bypass" tests
must set `kQuirkFilterOff` to keep that premise. (CLAP + AU v2 are NOT
wired — they have no bypass process path; injecting a param there would
appear-but-do-nothing, so they're a separate follow-up.)
No comments yet. Be the first to comment!