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.
Installs into .claude/skills of the current project.
Are you the author of Auv3?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/danielraffel-auv3)
---
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:** 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
When `PluginDescriptor::supported_bus_layouts` is non-empty, expose those main
input/output pairs through `channelCapabilities`. Keep that advertised list and
`Processor::is_bus_layout_supported()` aligned during negotiation.
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.
### Parameters: `AUParameterTree`
Parameter metadata is semantic, not range-inferred: `ParamInfo::kind` selects
continuous/integer/boolean/indexed behavior, and `value_labels` supplies the
reversible label table. String conversion must use the shared canonical helpers
so host text entry, labels, units, finite checks, snapping, and author-exception
containment stay identical to CLAP/VST3/AUv2/AAX.
`- (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.
- `implementorValueProvider` reads current values back from the store.
- `implementorStringFromValueCallback` delegates to
`ParamInfo::to_string` when provided, otherwise a `%.2f` fallback.
- `flags` is no longer the hard-coded `IsWritable | IsReadable`. It is derived
per parameter from the shared predicates `state::is_hidden_param` /
`is_read_only_param` / `is_automatable_param`, using the **same AU flag
mapping as the v2 adapter** (`au_v2_common.cpp` documents why each flag was
chosen): read-only withholds `IsWritable` and adds `MeterReadOnly`, hidden
adds the `ExpertMode` display hint, non-automatable adds `NonRealTime`. AU has
no literal hidden/readonly/automatable triple, so `ExpertMode` in particular
is a hint a host may ignore — do not describe it as hiding the parameter.
Keep the two AU adapters' mappings identical: they are separate code (this
tree vs `fill_parameter_info`) reading one model, and the whole point of the
shared predicates is that a plugin's parameters describe themselves the same
way in v2 and v3. Test: `test/test_au_param_visibility.mm` asserts both.
Worth knowing for the next flag: this was a `core/state` gap, not an AU one —
VST3 and CLAP could not express these either, because `ParamInfo` had no
field, so the fix was one model change rather than three adapter patches.
`__weak` capture + `strongSelf` null-check pattern is deliberate —
Obj-C blocks on `AUParameterTree` must not retain the audio unit.
#### UI → host automation write-back
For Logic (and any AUv3 host) to **record** automation when the user
moves a slider in the plugin's own editor, edits have to flow *back out*
through the `AUParameterTree` — observing the tree only carries host →
plugin. The adapter closes the loop:
- **Cache the tree.** `parameterTree` returns the same instance on every
call (`if (_parameterTree) return _parameterTree;`). The host's
automation-observer token is registered against *one* tree object; a
fresh tree per call would orphan the token and silently drop
automation. `_parameterTree` is `[tree retain]`-ed — **au_adapter.mm is
MRC, not ARC** (`-fno-objc-arc`), so the autoreleased tree must be
explicitly retained or it dangles and the next edit SIGSEGVs.
- **Audio-thread store listener.** An inline
`store.add_listener(cb, ListenerThread::Audio)` fires on every param
change; the callback calls
`[param setValue:v originator:_automationToken
atHostTime:0 eventType:AUParameterAutomationEventTypeValue]` so the
host sees the new value at the right address.
- **Gestures → Touch / Release.** `store.set_gesture_callbacks(begin,
end)` maps `begin_gesture`/`end_gesture` to
`AUParameterAutomationEventTypeTouch` and `…Release`. Without the
Touch/Release pair, Logic records values but won't arm a write pass.
The gesture callback is a **stateless C++ lambda** taking
`(PulpAudioUnit*, ParamID, eventType)` — an Obj-C stack-block captured
in a C++ `std::function` that outlives the scope would be use-after-
free under MRC (no automatic `Block_copy`).
- **Loop guard.** A `thread_local bool g_au_v3_host_writing` wraps the
`implementorValueObserver` write. The store listener checks it and
skips the write-back when the change *originated* from the host, so a
host automation move doesn't echo straight back into the host.
- **dealloc** must `set_gesture_callbacks({}, {})`, reset the listener
token, `removeParameterObserver:` the automation token, and
`[_parameterTree release]` — in that order — before the existing
`_mainThreadToken` teardown.
Test: `test/test_au_plugin_state.mm` →
`[au][auv3][params][automation]` drives
`begin_gesture`/`set_value`/`end_gesture` through the real store and
asserts a host-side `tokenByAddingParameterAutomationObserver` block
observes Touch + Value + Release (async — pump the runloop until the
event count reaches 3).
### 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 UMP (sysex7 **and** channel voice) arrives via
`AURenderEventMIDIEventList`. A host sends that second form **only** to a
unit that negotiates a MIDI protocol, so it is live exactly when the
Processor declares `supports_ump` — see "MIDI protocol negotiation" in the
gotchas below before assuming the sysex path runs at all.
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. Wraps the current main input, optional sidechain, and main output in a
stack-owned `ProcessBuffers` block, then calls the additive
`processor->process(process_buffers, midi_in, midi_out, ctx)` overload.
Legacy processors still run through the default projection; processors that
override the richer overload can inspect AUv3 bus metadata directly.
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`.
The bypass short-circuit (before step 6, when `shouldBypassEffect`) must NOT
`memcpy` the dry input straight to the output. When the Processor reports a
non-zero latency the host has delay-aligned the *wet* path by that latency
(PDC), so a raw dry copy arrives `latency` samples early — comb-filtering on
parallel busses. Route it through `boundary::render_bypass_passthrough`
(`adapter_boundary.hpp`), sizing the `AUBridge::bypass` delay line to
`reported_latency_samples(processor->latency_samples(), host_quirks)` in
`allocateRenderResources`. A zero latency collapses to a straight passthrough.
The AU v3 render block can't be driven in stock CI (needs a live host), so the
bypass logic is covered by the shared `test_adapter_boundary_parity.cpp`
`[bypass]` fixture the helper is exercised through.
### MPE routing
When a `Processor` declares `supports_mpe`, AU v3 feeds per-note expression the
same way CLAP/VST3 do — via the shared `boundary::MpeSidecar` (in
`adapter_boundary.hpp`), NOT a hand-rolled tracker. `AUBridge` owns one
`boundary::MpeSidecar mpe;`; `configure()` it at init, `reserve()+reset()` in
`allocateRenderResources`, `reset()` in `deallocateRenderResources`, and call
`mpe.run(*processor, midi_in)` in the render block **right before**
`processor->process()` and **after** the bypass early-return (a bypassed plugin
gets no MPE). AU delivers MIDI already time-ordered, so pass it in host order
(no sort) — matching CLAP. Non-MPE plugins get `set_mpe_input(nullptr)` each
block.
**The sidecar is unreachable unless the unit says so.** Logic (and every other
MPE-aware host) decides whether to route an MPE zone's per-member-channel
stream from the `supportsMPE` property; a unit that leaves it unanswered is
simply never offered MPE input, however complete its tracker. `PulpAudioUnit`
overrides `-supportsMPE` to return `_bridge.mpe.enabled` — the same descriptor
opt-in the render block gates on, so what the host is told and what the adapter
does cannot drift. The v2 bridge of the same property is
`kAudioUnitProperty_SupportsMPE`; keep both answering or an MPE plug-in works
in one adapter and not the other. Unlike bypass, MPE routing IS unit-tested headlessly: the AU v3 MPE case
in `test_au_plugin_state.mm` drives `internalRenderBlock` with a channel-wide
MIDI list and asserts per-note NoteOn/PitchBend/Timbre/Pressure routing.
### 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.
### Presets: `factoryPresets` + `currentPreset`
iOS hosts — AUM, Loopy Pro, Cubasis — drive presets **exclusively**
through this API, so an AUv3 that answers nothing here has no preset
UX at all on the platform Pulp ships to first.
`factoryPresets` builds `AUAudioUnitPreset` objects from the shared
`FactoryPresetTable` (`au_factory_presets.hpp`, the same table the
three AU v2 adapters serve `GetPresets` from), bound in
`initWithComponentDescription:`. It returns **nil, not an empty
array**, when the plug-in ships none — an empty array makes a host
draw an empty menu. The table is discovered from the loaded bundle, so
it is empty in any unit-test binary; a test stages a folder through
the Pulp-private `pulpFactoryPresetTable` accessor.
`setCurrentPreset:` implements Apple's documented split, and getting
it wrong is the trap here:
- **number >= 0 — a factory preset.** Load it from the table. It must
actually move `StateStore` values; a host that only sees the name
change will look like it is working.
- **number < 0 — a user preset.** Ask `super`'s
`presetStateFor:error:` and assign the result to `fullState`. Never
index the factory table with it.
`supportsUserPresets` therefore returns **YES**. Pulp owns no writable
preset store: `userPresets`, `saveUserPreset:error:`,
`deleteUserPreset:error:` and `presetStateFor:error:` all have
AUAudioUnit base-class implementations backed by a location Apple
manages, and `fullState` already round-trips the StateStore plus any
processor-owned payload. The two things a plug-in must supply are the
`YES` and the negative-number branch above.
Both branches are wrapped in
`will/didChangeValueForKey:@"allParameterValues"` — that pseudo-property
is how AUAudioUnit tells a host every parameter may have moved at once,
and `currentPreset` is one of the changes Apple names for it.
`currentPreset`'s accessors are **both** overridden, so nothing is
synthesized and the ivar must be retained and released in `-dealloc`
(this file is MRR, not ARC). A rejected selection — an out-of-range
factory number, an unreadable user preset — must leave both the
parameters and the previously reported `currentPreset` untouched.
### 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()`. Keep this property KVO-visible; it
is the AU scan hook ARA-aware hosts inspect.
### 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` — from
`ViewBridge::Options::hosted_editor()`, never a hand-assembled `Options`; a
structural test enforces that every hosted adapter uses the factory — 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
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 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 (and not equal to it). On iOS this is now enforced at
configure time: `pulp_add_ios_host_app` (`tools/cmake/PulpIosHostApp.cmake`)
FATAL_ERRORs ("must be nested under") if the extension id is not
strictly nested under the host id, so the mistake surfaces before any
build instead of at `xcrun simctl install`. Regression test:
`test/cmake/test_ios_hostapp_bundle_guard.sh`.
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. Adding
`ACCEPTS_MIDI` to `_pulp_add_auv3` without threading it through
`pulp_add_ios_auv3` is the representative failure mode; the
Coverage-macOS lane catches this class of mismatch because it
configures the iOS wrapper path.
## macOS AU v3 packaging — framework + stub .appex + container .app
**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."
That constraint is what forces the 3-tier shape. Pulp's macOS AU v3 lane
(`tools/cmake/PulpAuv3.cmake`'s `_pulp_add_auv3_macos_*` helpers,
on the macOS framework path) implements it:
```
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.
`pulp-build-info.json` (the build-identity record every bundle carries; see
the `ship` skill) goes into the `.appex` (format `AUv3`) and the container
`.app` (format `AUv3Host`), never the framework: `_pulp_attach_build_info`
skips `FRAMEWORK` targets because a `Contents/Resources` directory at a
framework root is an unsealed-root codesign failure. `pulp_add_ios_auv3()`
bypasses `pulp_add_plugin()`, so it attaches the record itself — keep that
call when editing either entry point.
**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.
### 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")
```
**`@executable_path/../../Frameworks` is the tempting wrong answer** — it
suits iOS's flat .appex layout, but breaks on macOS, where the .appex has
its own `Contents/MacOS/` and sits one bundle deeper. An rpath recipe
carried over from an iOS lane will resolve inside the .appex and the
framework silently fails to load.
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/Generous-Corp/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.
**`dealloc` is on the XPC queue too — reset the host on main.** The
same off-main hazard bites teardown: a GPU-backed `PluginViewHost`'s
`CVDisplayLink` idle pump is dispatched to the main queue and
dereferences the `ViewBridge`, so if the last controller release lands
on the XPC queue, freeing host + bridge off-main races a queued
main-queue idle block → SIGSEGV in `display_link_callback` (Ableton
Live "add plugin then delete" repro). Both `au_view_controller_mac.mm`
and `au_view_controller_ios.mm` `dealloc` now reset `_viewHost` via
`[NSThread isMainThread] ? reset : dispatch_sync(main, reset)` before
the reverse-order ivar destruction (flips the host liveness token +
stops the link first). Do NOT instead clear `idle_callback_` from the
off-main `dealloc` — that just swaps in a data race on the
`std::function`. See the `view-bridge` skill, "AU v3 teardown must ALSO
run on the main thread," for the full lifecycle.
### 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.
### AU v3 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.
Editor-initiated resize requests on iOS are advisory. Install the
`Processor::request_editor_resize` handler only after the bridge and host are
attached but before `ViewBridge::notify_attached()` invokes
`Processor::on_view_opened` (which may synchronously restore a mode-specific
size), require the main thread, validate the request through `ViewBridge`,
and publish the accepted size through `preferredContentSize`. Do not pin the
design viewport or replace the normal pane-bounds layout: the AU host remains
authoritative and the editor stays responsive. Register the handler under the
view controller's owner key. Clear that owner before swapping the audio unit
while keeping the old unit alive until main-thread cleanup, before
rebuilding/tearing down the editor, and in `dealloc`; otherwise a late request
can call through a stale controller or host. The XPC-queue setter must not read
or write the raw cached processor; that state is main-thread-owned. Captures
must remain unretained under both ARC and MRC, and the ARC `dealloc` path must
clear the handler too. In macOS teardown, check `isViewLoaded` before reading
`self.view` so deallocation cannot lazily construct a new editor.
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; this 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): 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**:
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.
### The `PULP_AUV3_PLUGIN()` macro replaces hardcoded force_link
Before the per-plugin AU v3 entry macro, `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. The current adapter 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
```
The sysex7 reassembly state machine no longer lives inline in
`au_adapter.mm` — it 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 critical 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.
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. The same visitor now also handles types 0x1 (system),
0x2 (MIDI 1.0 channel voice) and 0x4 (MIDI 2.0 channel voice); anything
else is skipped by its true word length. Do not narrow it back to
sysex-only — that is what silently discarded every note a MIDI
2.0 host delivered.
Sysex7 size is still 0..6 bytes per 2-word packet; preserve message
boundaries. The reassembler clamps to 6
defensively.
Both invariants are tested by
`test/test_ump_sysex7_reassembler.cpp` (the regression 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.
### MIDI protocol negotiation — an AU that says nothing gets 7-bit MIDI
`AUAudioUnit.h` is explicit: an AU that does not override
`AudioUnitMIDIProtocol` "will default to receiving legacy MIDI". That is not a
transport detail — the host performs the narrowing, so 16-bit velocity and
32-bit controller values are already 7-bit by the time the render block runs.
No amount of UMP machinery downstream can recover them.
`PulpAudioUnit` overrides it:
```objc
- (MIDIProtocolID)AudioUnitMIDIProtocol {
if (_bridge.ump_enabled) return kMIDIProtocol_2_0;
return [super AudioUnitMIDIProtocol];
}
```
`ump_enabled` is latched once at init from
`descriptor().effective_capabilities().supports_ump`. Three consequences worth
knowing before touching this:
1. **It decides whether `AURenderEventMIDIEventList` arrives at all.** A host
delivers event lists only to a unit that negotiated a protocol. Before this
override existed the whole event-list branch — sysex7 reassembly included —
was code no conforming host reached.
2. **Negotiating without decoding channel voice loses notes.** Once the unit
says MIDI 2.0, the host routes *everything* through event lists as type 0x4.
The walk must decode 0x2/0x4, not just 0x3.
3. **Publish both transports.** The render block appends channel-voice packets
to `AUBridge::ump_buffer` (pre-reserved + capacity-limited, published via
`set_ump_input()` for the duration of `process()`) *and* projects each to
MIDI 1.0 into `midi_in` via `ump_to_midi1_event`. The projection is what
keeps every existing `MidiBuffer` reader — and the MPE sidecar, which runs
off `midi_in` — working under MIDI 2.0. Packets with no MIDI 1.0 form
(per-note pitch bend, per-note CC) reach an opted-in Processor through the
UMP buffer only. `set_ump_input(nullptr)` for a Processor that did not opt
in, so its behaviour is byte-for-byte what it was.
Gate every new behaviour here on the descriptor flag. A plug-in that does not
declare `supports_ump` must negotiate nothing and see exactly today's stream.
Coverage: `test_au_plugin_state.mm` drives `internalRenderBlock` with a real
`MIDIEventList` of type-0x4 messages and asserts a 16-bit velocity survives
intact, that per-note pitch bend arrives (it has no MIDI 1.0 form, so it proves
native delivery rather than a round trip), and that a non-declaring plug-in
still reports a non-2.0 protocol, gets a null `ump_input()`, and keeps its
notes.
### AUv3 MIDI *output* is still short-message only
`MIDIOutputEventBlock` is packed from `uint8_t bytes[3]`, so a Processor's
output sysex does not reach an AUv3 host — while the AU v2 adapter carries it
(`MidiOutputPacketBuilder` in `au_v2_common.hpp`). Closing the gap means
`MIDIOutputEventListBlock` plus an outbound UMP sysex7 **packetizer**, which
`core/midi` does not have (it has a reassembler, the inbound half only). Don't
"fix" it by truncating; it is an unimplemented path, not a bug in this one.
### 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()`.
### `renderContextObserver` is the parallel-renderer workgroup handoff
`PulpAudioUnit` always returns an `AURenderContextObserver` block. At init it
checks whether the Processor also implements the separate
`format::AudioWorkgroupClient` capability (kept out of the Processor vtable for
node-ABI stability). The observer atomically forwards
`AudioUnitRenderContext::workgroup`, including a null context/workgroup for
removal. Apple invokes the block on the realtime render thread immediately
before a changed-context render and requires the plug-in to prepare auxiliary
threads to leave the preceding workgroup and join the new one. The adapter
therefore publishes a generation and completes the worker pool's
full-participant acknowledgment barrier before returning. The barrier is
allocation-free and lock-free; do not replace it with a mutex, condition
variable, scheduler yield, thread join, or reference-count operation. A failed
non-null join is acknowledged as a completed leave of the old context and keeps
the renderer inline for the new publication.
Do not retain the borrowed OS handle in the adapter or call `os_workgroup_join`
from the observer on behalf of another thread. A SequenceProcessor opts in by
deriving from `AudioWorkgroupClient`; ordinary processors pay only the cached
null capability pointer and the observer is a no-op.
`deallocateRenderResources` runs off the realtime path. It publishes explicit
render-context removal and waits for every auxiliary worker to leave before
calling `Processor::release()`, so a persistent graph pool cannot remain joined
to a host-owned workgroup after its render resources are gone.
### `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
`initialize` auto-detects the plugin's bypass parameter via the shared
`pulp::state::is_bypass_param` contract 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). **Param designation:** a Processor can declare
`ParamInfo::designation = ParamDesignation::Bypass` to mark its bypass
control *independent of name*; the legacy boolean-`"Bypass"` name/range
heuristic is the fallback for params that declare none, so existing
plugins are unchanged. 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.
**Trigger params:** the bridge calls `bridge->store.reset_triggers_rt()`
to auto-reset trigger / momentary params (`ParamInfo::is_trigger`, or a
`ParamDesignation::Reset` "reset/panic" control) back to their default as
a **single-exit invariant** — both after `Processor::process` on the
normal path AND before the bypass short-circuit's `return noErr`, so a
panic/reset raised while bypassed clears this block instead of the next
active one. The retained `AUParameterTree`'s `implementorValueProvider`
reads the store **live** (`store.get_value(param.address)`), so the host
reflects the settled value on its next poll/query of the parameter. Note
there is no KVO push on the reset — `AUParameter.value` (the cached
property) is not actively notified; AU hosts re-query through the value
provider, which is the same "reflect on next read" contract VST3/CLAP
have outside their output-event scan. If a future host needs an active
push, add an AU-safe deferred main-thread notify keyed on reset trigger
params.
`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.
### Offline-render routing — `renderingOffline` → `ProcessMode::Offline`
A host doing a faster-than-real-time bounce sets `AUAudioUnit.renderingOffline =
YES` before rendering and back to `NO` afterward. The adapter mirrors the bypass
pattern: `setRenderingOffline:` calls `super` and stores the flag in a bridge-local
atomic (`rendering_offline`), and the render block reads it (acquire) to set
`ctx.process_mode = Offline` / `render_speed_hint = FasterThanRealtime`. This lets a
processor switch to an offline-only code path — e.g. an async GPU engine that, in
realtime, hands the audio thread a worker-produced result and **drops to silence on
a miss**; an offline render runs faster than the wall-clock worker, so without this
hook every block misses and the wet (reverb/delay tail) is dropped from the bounce.
The processor's offline path should drive that work **synchronously** (blocking
readback is fine offline — no RT deadline). Trust model is the same as bypass: the
host is expected to clear `renderingOffline` when returning to live playback; a host
that leaves it set would keep the processor on the (blocking) offline path during
realtime. VST3 already surfaces this via `ProcessSetup.processMode == kOffline`;
AU v2 and CLAP do not surface offline intent (documented limitation).
### Latency / tail change notifications
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
**Removed legacy path:** `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.
**Current path:** 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
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.
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
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
out of scope** for the current AU v3 lane.
### Xcode-project generation: `pulp ship auv3-xcodeproj`
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 + build hint
```
The wrapper runs `cmake -G Xcode` against a **separate build dir**
(default `build/xcode/<target>-<sdk>`) so it doesn't collide with the
user's normal Ninja/Makefile cache. The generated build hint selects
`<target>_AUv3`; CMake configure still generates the project normally. iOS
SDKs pull in `tools/cmake/ios.toolchain.cmake` with the correct
`IOS_PLATFORM` (OS for device, SIMULATOR64 for simulator). On macOS, the
generated project also includes `<target>_AUv3Host`, the runnable containing
app. This is the supported Xcode iteration path for AU v3 targets.
### 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
`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.
**Updated (WAH-1): subscribe, do not sample.** The one-shot
`attach_gpu_surface(host->gpu_surface())` read this section used to
describe is GONE. It only worked on hosts that build their surface in
the constructor; the Windows host creates its Dawn surface inside
`attach_to_parent()`, so the read returned `nullptr` forever and every
Windows editor fell back to mock WebGPU. Adapters now call the shared
helper once:
```cpp
gpu_surface_binding_ = bind_gpu_surface(*host, bridge->scripted_ui(),
gpu_decision, "mac AUv3 / iOS AUv3");
```
It follows `PluginViewHost::observe_gpu_surface()`, forwards creation
AND teardown into the session, and owns the CPU-fallback diagnostic
(which no longer fires on a pre-attach `pending` state). Reset the
returned subscription in the editor-close path, before the bridge that
owns the session is destroyed.
- `docs/guides/ios-auv3-guidance.md` — the human-facing iOS AUv3 guide.
- `docs/guides/formats.md` — user-facing format overview + auval
recipes.
- Cross-format sysex sidecar note: AU, VST3, CLAP, and AAX sysex
handling all share the same sidecar shape. Fixing one means checking
the other three.
## Host-quirks consumption
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
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 need separate design work.)
## The StateStore must outlive the Processor
`Processor::state()` dereferences a pointer the host installs. A Processor may
follow it for its whole lifetime — from `process()`, from its destructor, and from
any worker thread that destructor is about to `join()`. So the host has to keep the
store alive until the Processor is gone.
In practice that is one rule about member order: **declare the `state::StateStore`
before the `std::unique_ptr<Processor>`.** Members are destroyed in reverse
declaration order, so the store then dies last. Every host in `core/format` had it
backwards until 2026-07; the effect is nothing at all for a Processor with no
threads, and a use-after-free on plug-in close for one with a background thread that
reads `state().get_value()` while the destructor walks to its `join()`.
It crashes only on close, only sometimes, and the DAW gets the blame. The regression
test is `test/test_store_lifetime.cpp`; it observes the store's destruction through a
sentinel owned by a parameter's `to_string` closure rather than reading freed memory
and hoping the result looks wrong.
A Processor should not *rely* on this either: a worker thread that reads the store on
every tick is one host away from the same crash. Publish what the thread needs to
atomics from `process()` instead.
## UMP word lengths come from core/midi, and the MIDIEventList walk is testable
Do not hand-roll a words-per-message-type table in the adapter. `core/midi`'s
`ump_words_for_message_type(uint8_t)` is the single spec-complete source (every nibble
0x0-0xF, including the reserved ranges and the 128-bit UMP-Stream type 0xF), and
`UmpPacket::size_for_type` delegates to it. A second, under-covering table is exactly the
UMP-cursor-advance bug class: advancing an unrecognized message by 1 word re-reads its
trailing words as fresh headers. The AU v3 MIDIEventList word-cursor walk is extracted out
of the ObjC render block so it is unit-testable (truncated-packet + multi-word-advance
vectors) rather than only reachable through a live AU host.
## `setFullState:` runs under the state-restore gate
The property table at the top of `au_adapter.mm` marks `setFullState:` as
main-thread with serdes. Hosts call it while the unit is rendering, and
`Processor::deserialize_plugin_state()` is documented as running with the audio
thread stopped, so the setter holds `AUBridge::state_restore_gate` across the
deserialize and the render block takes the matching non-blocking render lock.
The render lock is acquired BEFORE the MPE sidecar phase, because
`bridge->mpe.run(*bridge->processor, …)` already reaches into the Processor. On
contention the block passes its input through, clears the sidechain pointer and
resets triggers, then returns `noErr` — deliberately NOT via `failClosed()`,
which would additionally set `kAudioUnitRenderAction_OutputIsSilence` and turn a
brief restore into a labelled dropout.
## AU has no note-name surface
`Processor::note_names()` lets a plug-in label individual keys — a drum kit's
"Kick", a sampler's articulation switches — and CLAP and VST3 both publish it.
AU has no host-side equivalent in either v2 or v3, so `note_names()` simply goes
unread on this format.
That is a gap in the AU API, not an omission in the adapter. Do not invent a
private property for it.
### Tracing attaches for this format now (WAH-4)
Perfetto tracing used to be wired into **VST3 only**. A capture of a AU v3
session recorded nothing while `Tracing`'s API described itself as
process-global — so an empty `.pftrace` looked like an environment problem
rather than a missing call.
This adapter now holds a `runtime::ScopedTracingAttachment` (`PulpAudioUnit`'s `_tracing` ivar, reset explicitly at the end of `-dealloc`). Two
things follow:
- **It is RAII, not a hand-balanced attach/detach pair.** A leaked attachment
is not benign: the `.pftrace` is only written by the FINAL detach, so one
unbalanced instance means the capture silently produces nothing.
- **Declaration order is load-bearing.** It must outlive every span this
instance can emit, so it is declared to destroy LAST. The final detach also
cancels and JOINS the auto-flush timer, which is what makes plug-in module
unload safe — a detached timer thread that wakes after `FreeLibrary` /
`dlclose` runs freed code.
No-op unless the build is configured `PULP_TRACING=ON`.
## The bundle carries its own icon, and the plist key is what makes it work
`pulp_app_icon(<target>_AU ...)` brands the appex: it copies the
`.icns` into `Contents/Resources/` and sets `MACOSX_BUNDLE_ICON_FILE`, which
CMake substitutes into the bundle's `Info.plist` at generate time.
The load-bearing half is easy to miss. That substitution needs a
`CFBundleIconFile` key in `tools/cmake/PulpInfoPlist.au.in` to land in.
Without it the copy still happens and the property is still set, so nothing
errors — the bundle just comes out unbranded. If an icon does not appear,
check the template for the key before suspecting the helper.
Prefer `ICNS` over `SOURCE` for a mark with fine detail. `SOURCE` derives
every size from one PNG with `sips`, whose Lanczos kernel overshoots on hard
edges: a feature one or two device pixels wide at 16x16 smears into its
neighbours and the bundle edge picks up a bright halo. Render each size on
its own pixel grid and pass the finished `.icns`.