| 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.
__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:
- Zeroes outputs and returns
noErr if the Processor is null (host
calling render before allocateRenderResources succeeded).
- 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.
- 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).
- 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.
- 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.
- 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.
- 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. 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.
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(). 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 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:
-
.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:
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.
-
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).
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
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.
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):
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:
- Sign embedded dylibs first (
libwgpu_native.dylib, etc.) with
--timestamp --options runtime and the same Developer ID identity
- Sign the framework
- 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.
- Sign the container .app (also with hardened-runtime entitlements
for library validation)
- Notarize the container .app via
xcrun notarytool submit --apple-id <id> --team-id <team> --password <app-specific-pwd> --wait
- Staple with
xcrun stapler staple
- Install to /Applications and
lsregister -f -R
- 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 —
schema template + sourceable helper, no per-user identifiers in
committed code.
Diagnostic for silent Pluginkit rejection:
pluginkit -mAvvv -p com.apple.AudioUnit-UI | grep <your-plugin>
auval -a | grep <your-fourcc>
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.
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:
- (void)rebuildEditorIfReady {
if (![NSThread isMainThread]) {
dispatch_async(dispatch_get_main_queue(), ^{
[self rebuildEditorIfReady];
});
return;
}
}
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:
killall -9 cfprefsd
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
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.
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:
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.
rebuildEditorIfReady opens the ViewBridge, sets preferredContentSize,
wires onResize, sets _viewHostPending + _pendingRoot, and does not
create the host or force the view frame.
-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).
- 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:
- 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.
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; 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.
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_asyncs 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:
#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:
~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.
~View (_fallbackView) runs next — no-op on the bridge-success
path; on the fallback path the back-pointer was cleared in step 1.
~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:
./build/pulp build
./build/pulp validate
Manual auval (macOS only — auval is an Apple tool):
auval -a
auval -v aufx MyPl Plup
auval -v aumu MySy Plup
If auval -a doesn't list the plugin, the AU cache is stale. Reset
it:
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:
- 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).
- iOS device — single monolithic
.appex produced by
_pulp_add_auv3_ios; signed with the
templates/auv3/iOS-Device-Entitlements.plist.template entitlements
(application-groups).
- 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:
pulp ship auv3-xcodeproj <target>
pulp ship auv3-xcodeproj <target> --sdk iphoneos
pulp ship auv3-xcodeproj <target> --sdk macosx
pulp ship auv3-xcodeproj <target> --output build/xcode/MyPlugin
pulp ship auv3-xcodeproj <target> --open
pulp ship auv3-xcodeproj <target> --dry-run
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():
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.
- 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.