From pulp
Guides work on Pulp's AUAudioUnit subclass for AUv3 format: render-block wiring, parameter bridging, MIDI/sysex, sidechain, state persistence, iOS extension surface, and auval validation.
How this skill is triggered — by the user, by Claude, or both
Slash command
/pulp:auv3The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when touching Pulp's Audio Unit v3 adapter, when
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.
core/format/src/au_adapter.mm — the PulpAudioUnit
AUAudioUnit subclass.core/format/src/au_entry.mm — the
AUAudioUnitFactory-conforming entry object (PulpAUFactoryObj)
and the PulpAUFactory component entry symbol.core/format/src/au_audio_unit.h — the Obj-C forward
declaration used by au_entry.mm and the iOS 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.auval pass regresses.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.
| 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.
AUAudioUnit, not AUEffectBaseAU 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.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.PulpAUFactory + AUAudioUnitFactorymacOS: 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.
Inside initWithComponentDescription:…:
AUAudioUnitBus at 48 kHz default with
desc.default_output_channels() channels.desc.default_input_channels() > 0, one
AUAudioUnitBus with that channel count.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.
AUParameterTree- (AUParameterTree *)parameterTree builds one AUParameter per
StateStore param:
AUParameterAddress is the Pulp ParamID cast to uint64_t.unit is mapped from Pulp's unit string
(dB → kAudioUnitParameterUnit_Decibels, Hz → _Hertz, % →
_Percent, boolean-shaped ranges → _Boolean, everything else →
_Generic).implementorValueObserver writes host param changes into
store.set_value_rt(id, value). AUv3 hosts may invoke the observer
from arbitrary threads, including the render thread — use
set_value_rt, not the generic set_value. The RT path writes the
atomic and pushes an SPSC event for ListenerThread::Main listeners;
the editor drains via store.pump_listeners(). The generic path
would heap-allocate the dispatch lambda on a possibly-audio thread.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.
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:
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.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.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).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.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).
- (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:
noErr if the Processor is null (host
calling render before allocateRenderResources succeeded).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.pullInputBlock(…, 0, &input_abl). This
reuses the output buffers as the input destination — in-place
processing is allowed (canProcessInPlace returns YES).pullInputBlock(…, 1, &sidechain_abl) into the separate backing
storage; publishes to processor->set_sidechain(&view) only on
success, nulls out the slot on failure.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.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.midi_out events back to the host via
self.MIDIOutputEventBlock (AU v3.1+). Each event's
sample_offset is added to timestamp->mSampleTime.fullState dictionaryfullState 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.
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.
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 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.
When the HostApp shows "(no AUv3 found)" or instantiate fails silently:
// 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 →
file <appex>/<exec> says bundle not executable).options: [] instead of .loadOutOfProcess).xcrun simctl spawn booted log show --last 30s --predicate 'eventMessage CONTAINS "PulpSineSynth" AND (eventMessage CONTAINS "Exec format" OR eventMessage CONTAINS "posix_spawn")'.Simulator does NOT capture audio in simctl io booted recordVideo
(video-only). For audio verification you need either:
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.
Apple's architecture, not a Pulp invention. Get this wrong and you will spend an entire session debugging silent Pluginkit rejections.
Apple's "Creating custom audio effects" sample doc states verbatim:
"Your extension's main binary cannot be dynamically loaded into another app, which means all executable AU code must reside in a separate framework bundle. The extension target still needs to contain at least one source file for the extension binary to be created, properly loaded, and linked with the framework bundle."
iPlug2 ships the same 3-tier architecture. Pulp's macOS AU v3 lane
(tools/cmake/PulpAuv3.cmake's _pulp_add_auv3_macos_* helpers,
on the macOS framework path) follows this pattern exactly:
ChainerSynth.app/ ← container .app
├── Contents/
│ ├── MacOS/ChainerSynth ← tiny Cocoa shell, launched once to register
│ ├── PlugIns/
│ │ └── ChainerSynth.appex/ ← stub .appex (NSExtensionMain entry)
│ │ └── Contents/
│ │ ├── Info.plist
│ │ │ • NSExtensionPointIdentifier = com.apple.AudioUnit-UI
│ │ │ • NSExtensionPrincipalClass = PulpAUMacViewController
│ │ │ • NSExtensionAttributes.AudioComponentBundle =
│ │ │ <bundle-id>.AUv3Framework ← MUST match framework's CFBundleIdentifier
│ │ └── MacOS/ChainerSynth ← ~50KB stub binary, links framework
│ └── Frameworks/
│ └── ChainerSynthAUv3Framework.framework/ ← REAL code lives here
│ ├── Info.plist (CFBundlePackageType=FMWK)
│ └── Versions/A/
│ ├── ChainerSynthAUv3Framework ← contains PulpAudioUnit + PulpAUMacViewController
│ └── libwgpu_native.dylib ← any embedded dylibs
iOS is different — iOS AU v3 still uses the monolithic .appex. The
framework split is macOS-specific because of Apple's loadInProcess
out-of-process requirement on macOS. pulp_add_ios_auv3() stays on the
legacy monolithic path; pulp_add_plugin(FORMATS AUv3) dispatches to
the macOS framework path on macOS.
_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)._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._pulp_add_auv3_macos_host): tiny Cocoa shell
with a "this is the registration host" placeholder window. Bundle ID
<plugin-bundle-id>.AUv3Host. The user runs it once after install
to trigger Launch Services scan.Do NOT put au_entry.mm's PulpAUFactoryObj (legacy
AudioComponentRegister factory C function) anywhere in the macOS AU v3
lane. The macOS path uses _NSExtensionMain + NSExtensionPrincipalClass
to find the factory class. iPlug2 follows the same convention.
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")
iPlug2's modern CMake helper has this wrong — it sets
@executable_path/../../Frameworks which works for iOS's flat .appex
layout but breaks macOS where the .appex has its own
Contents/MacOS/. Don't copy that recipe.
The container .app's binary at MyApp.app/Contents/MacOS/MyApp needs
2 parent dirs up: INSTALL_RPATH "@executable_path/../Frameworks".
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:
libwgpu_native.dylib, etc.) with
--timestamp --options runtime and the same Developer ID identity--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.xcrun notarytool submit --apple-id <id> --team-id <team> --password <app-specific-pwd> --waitxcrun stapler staplelsregister -f -RThe 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:
# 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.
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.
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.
createAudioUnit:error: runs on the XPC queue, NOT mainThe 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;
}
// ... AppKit work
}
Don't just guard at setAudioUnit: (the property setter). The
compiler can inline through the property setter when createAudioUnit
assigns to self.audioUnit, bypassing your thread check. The hard
guard inside rebuildEditorIfReady is the only safe place. Same
gotcha on iOS — au_view_controller_ios.mm has the same guard.
Logic 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:
# 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.
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.
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).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.
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.
PULP_AUV3_PLUGIN() macro replaces hardcoded force_linkBefore 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.
AURenderEventMIDIEventList = UMP — not short MIDI, not raw sysexAU 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:
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.
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 blockThe 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() livesNot in initWithComponentDescription:. The host may instantiate the
audio unit to enumerate parameters / buses without ever rendering;
calling Processor::prepare() before the host has a sample rate +
max frames in hand wastes work and can mis-size buffers. Mirror:
deallocateRenderResources calls processor->release().
tailTime is in seconds, not samplesPulp'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.
initialize auto-detects a plugin-declared "Bypass" parameter and
routes both AU v3 bypass surfaces (the host's bypass AUValue and
the plugin's automation lane) through the same StateStore atomic
so they stay in lockstep (DAW quirks row 21). When no Bypass param
exists the bridge falls back to a local atomic so the contract still
holds for plugins that don't declare one.
internalRenderBlock short-circuits to pass-through audio when
bypassed (in→out for effects, silence for instruments) and never
calls Processor::process. MIDI output stays empty so bypassed
MIDI FX don't leak notes. Diagnostic: read pulpBypassParameterId
on PulpAudioUnit (also exposed from the shared au_audio_unit.h
header) to confirm which ParamID got picked up.
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).
AudioBufferListAliasing 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.
PULP_AUV3_PLUGIN(), NOT a hand-rolled force_linkRemoved 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.
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 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 gateauval 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.
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() explicitlyThe 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.
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.
Build and validate via the Pulp CLI:
./build/pulp build
./build/pulp validate # runs auval via the auval-<name> CTest target
Manual auval (macOS only — auval is an Apple tool):
# 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:
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.
The AU v3 packaging shape is three distinct targets, dispatched by
_pulp_add_auv3 in tools/cmake/PulpAuv3.cmake:
${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)..appex produced by
_pulp_add_auv3_ios; signed with the
templates/auv3/iOS-Device-Entitlements.plist.template entitlements
(application-groups)._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.pulp ship auv3-xcodeprojOnce 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> # iphonesimulator (default)
pulp ship auv3-xcodeproj <target> --sdk iphoneos # device
pulp ship auv3-xcodeproj <target> --sdk macosx # macOS lane
pulp ship auv3-xcodeproj <target> --output build/xcode/MyPlugin
pulp ship auv3-xcodeproj <target> --open # open in Xcode after gen
pulp ship auv3-xcodeproj <target> --dry-run # print cmake invocation only
The wrapper runs cmake -G Xcode -DPULP_AUV3_TARGET=<name> against
a separate build dir (default build/xcode/<target>-<sdk>) so
it doesn't collide with the user's normal Ninja/Makefile cache. iOS
SDKs pull in tools/cmake/ios.toolchain.cmake with the correct
IOS_PLATFORM (OS for device, SIMULATOR64 for simulator). This is
the supported Xcode iteration path for AU v3 targets.
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.
.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.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.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.
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.)
npx claudepluginhub danielraffel/pulp --plugin pulpValidates AudioUnit v2/v3 plugins (.component bundles and .appex app extensions) on macOS using Apple's auval tool, including registration and rescan steps.
Integrates Visage GPU-accelerated UI framework into JUCE audio plugins for macOS and iOS/iPadOS. Covers Metal view embedding, event bridging, focus management, keyboard handling, popups, memory management, and Visage API reference.