本文へ移動
cccskills
無料GitHub で公開

auv3

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.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md83.3 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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

RolePath
Core adapter (Obj-C++)core/format/src/au_adapter.mm
Forward declaration used by entry / viewcore/format/src/au_audio_unit.h
Component entry factorycore/format/src/au_entry.mm
iOS AUv3 extension view controllercore/format/src/au_view_controller_ios.mm (also mapped to ios + view-bridge skills)
iOS AU audio session helpercore/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)
Teststest/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 invocationtools/cli/cmd_validate.cpp — runs auval via the auval-<name> CTest target

There is no PulpAU3.cmake; AU v3 targets are wired directly in the top-level CMake plugin helpers alongside AU v2 and the iOS extension target.

Core conventions

AUAudioUnit, not AUEffectBase

AU v3 uses AUAudioUnit as the plugin base class — Apple's modern, block-based render API. AU v2 (AUEffectBase) and AU v3 (AUAudioUnit) are two different C++ classes and two separate .component bundles. Pulp ships both where applicable; the v3 subclass is PulpAudioUnit in au_adapter.mm.

The bridge struct pulp::format::au::AUBridge owns:

  • std::unique_ptr<Processor> processor + state::StateStore store — the same Pulp DSP + state objects used by the CLAP/VST3 adapters.
  • Pre-allocated output_ptrs, input_ptrs, sidechain_ptrs (sized to kMaxChannels = 8) so the render block never allocates.
  • InputBufferStorage input_abl and SidechainBufferStorage sidechain_abl — pre-sized AudioBufferList structs for the AURenderPullInputBlock pulls. The sidechain has its own ABL so it doesn't alias the main input pull.
  • sidechain_storage — a std::vector<float> backing buffer for the sidechain pull so the adapter can stay allocation-free on the audio thread after allocateRenderResources.
  • param_events (state::ParameterEventQueue) — cleared each render block and filled from inbound AU parameter/ramp render events with their sample offsets while the StateStore dual-write keeps normal block-level parameter reads current.

Entry point: PulpAUFactory + AUAudioUnitFactory

macOS: the .appex stub uses Apple's _NSExtensionMain entry point. NSExtensionPrincipalClass in Info.plist points at PulpAUMacViewController, which adopts AUAudioUnitFactory directly — its createAudioUnitWithComponentDescription:error: allocs a PulpAudioUnit. The legacy PulpAUFactory C component-registration function is NOT used on macOS; everything goes through PlugInKit's extension lifecycle. See the "macOS AU v3 packaging" section below for the full framework + stub .appex + container .app architecture.

iOS (legacy monolithic .appex): au_entry.mm still defines PulpAUFactoryObj (NSObject conforming to <AUAudioUnitFactory>) and the C entry symbol PulpAUFactory(const AudioComponentDescription*). iOS uses the same _NSExtensionMain path via Apple's AUViewController-based principal class (PulpAUViewController in au_view_controller_ios.mm); the C entry remains as a no-op safety net for AudioComponentInstantiate-style direct loads. The PULP_AUV3_PLUGIN() macro (in <pulp/format/au_v3_entry.hpp>) is what registers the per-plugin processor factory at static init — no force_link shim required.

Bus construction

When PluginDescriptor::supported_bus_layouts is non-empty, expose those main input/output pairs through channelCapabilities. Keep that advertised list and Processor::is_bus_layout_supported() aligned during negotiation.

Inside initWithComponentDescription:…:

  • Output bus: one AUAudioUnitBus at 48 kHz default with desc.default_output_channels() channels.
  • Main input bus: when desc.default_input_channels() > 0, one AUAudioUnitBus with that channel count.
  • Sidechain bus: when desc.input_buses.size() > 1 and desc.input_buses[1].default_channels > 0, a second input AUAudioUnitBus — bus index 1. Hosts connect their sidechain source to bus index 1 and the render block pulls it from that index.

This mirrors the CLAP / VST3 "bus 0 = main, bus 1 = sidechain" rule.

Parameters: AUParameterTree

Parameter metadata is semantic, not range-inferred: ParamInfo::kind selects continuous/integer/boolean/indexed behavior, and value_labels supplies the reversible label table. String conversion must use the shared canonical helpers so host text entry, labels, units, finite checks, snapping, and author-exception containment stay identical to CLAP/VST3/AUv2/AAX.

- (AUParameterTree *)parameterTree builds one AUParameter per StateStore param:

  • AUParameterAddress is the Pulp ParamID cast to uint64_t.

  • unit is mapped from Pulp's unit string (dB → kAudioUnitParameterUnit_Decibels, Hz → _Hertz, % → _Percent, boolean-shaped ranges → _Boolean, everything else → _Generic).

  • implementorValueObserver writes host param changes into store.set_value_rt(id, value). AUv3 hosts may invoke the observer from arbitrary threads, including the render thread — use set_value_rt, not the generic set_value. The RT path writes the atomic and pushes an SPSC event for ListenerThread::Main listeners; the editor drains via store.pump_listeners(). The generic path would heap-allocate the dispatch lambda on a possibly-audio thread.

  • implementorValueProvider reads current values back from the store.

  • implementorStringFromValueCallback delegates to ParamInfo::to_string when provided, otherwise a %.2f fallback.

  • flags is no longer the hard-coded IsWritable | IsReadable. It is derived per parameter from the shared predicates state::is_hidden_param / is_read_only_param / is_automatable_param, using the same AU flag mapping as the v2 adapter (au_v2_common.cpp documents why each flag was chosen): read-only withholds IsWritable and adds MeterReadOnly, hidden adds the ExpertMode display hint, non-automatable adds NonRealTime. AU has no literal hidden/readonly/automatable triple, so ExpertMode in particular is a hint a host may ignore — do not describe it as hiding the parameter.

    Keep the two AU adapters' mappings identical: they are separate code (this tree vs fill_parameter_info) reading one model, and the whole point of the shared predicates is that a plugin's parameters describe themselves the same way in v2 and v3. Test: test/test_au_param_visibility.mm asserts both. Worth knowing for the next flag: this was a core/state gap, not an AU one — VST3 and CLAP could not express these either, because ParamInfo had no field, so the fix was one model change rather than three adapter patches.

__weak capture + strongSelf null-check pattern is deliberate — Obj-C blocks on AUParameterTree must not retain the audio unit.

UI → host automation write-back

For Logic (and any AUv3 host) to record automation when the user moves a slider in the plugin's own editor, edits have to flow back out through the AUParameterTree — observing the tree only carries host → plugin. The adapter closes the loop:

  • Cache the tree. parameterTree returns the same instance on every call (if (_parameterTree) return _parameterTree;). The host's automation-observer token is registered against one tree object; a fresh tree per call would orphan the token and silently drop automation. _parameterTree is [tree retain]-ed — au_adapter.mm is MRC, not ARC (-fno-objc-arc), so the autoreleased tree must be explicitly retained or it dangles and the next edit SIGSEGVs.
  • Audio-thread store listener. An inline store.add_listener(cb, ListenerThread::Audio) fires on every param change; the callback calls [param setValue:v originator:_automationToken atHostTime:0 eventType:AUParameterAutomationEventTypeValue] so the host sees the new value at the right address.
  • Gestures → Touch / Release. store.set_gesture_callbacks(begin, end) maps begin_gesture/end_gesture to AUParameterAutomationEventTypeTouch and …Release. Without the Touch/Release pair, Logic records values but won't arm a write pass. The gesture callback is a stateless C++ lambda taking (PulpAudioUnit*, ParamID, eventType) — an Obj-C stack-block captured in a C++ std::function that outlives the scope would be use-after- free under MRC (no automatic Block_copy).
  • Loop guard. A thread_local bool g_au_v3_host_writing wraps the implementorValueObserver write. The store listener checks it and skips the write-back when the change originated from the host, so a host automation move doesn't echo straight back into the host.
  • dealloc must set_gesture_callbacks({}, {}), reset the listener token, removeParameterObserver: the automation token, and [_parameterTree release] — in that order — before the existing _mainThreadToken teardown.

Test: test/test_au_plugin_state.mm → [au][auv3][params][automation] drives begin_gesture/set_value/end_gesture through the real store and asserts a host-side tokenByAddingParameterAutomationObserver block observes Touch + Value + Release (async — pump the runloop until the event count reaches 3).

Render block

- (AUInternalRenderBlock)internalRenderBlock returns a block that captures a raw &_bridge pointer (Obj-C __block / ARC semantics do not apply — _bridge is a C++ struct). The block:

  1. Zeroes outputs and returns noErr if the Processor is null (host calling render before allocateRenderResources succeeded).
  2. Rejects frameCount > maximumFramesToRender with kAudioUnitErr_TooManyFramesToProcess. If a host or validator passes null or undersized outputData->mBuffers[i].mData, the adapter assigns slices from AUBridge::output_storage, which is pre-sized in allocateRenderResources. Do not heap-allocate in the steady-state render path.
  3. Pulls the main input via pullInputBlock(…, 0, &input_abl). This reuses the output buffers as the input destination — in-place processing is allowed (canProcessInPlace returns YES).
  4. Pulls the sidechain (if enabled) via pullInputBlock(…, 1, &sidechain_abl) into the separate backing storage; publishes to processor->set_sidechain(&view) only on success, nulls out the slot on failure.
  5. Walks the realtime event list — parameter/ramp events append to param_events and call store.set_value_rt, short MIDI arrives via AURenderEventMIDI, and UMP (sysex7 and channel voice) arrives via AURenderEventMIDIEventList. A host sends that second form only to a unit that negotiates a MIDI protocol, so it is live exactly when the Processor declares supports_ump — see "MIDI protocol negotiation" in the gotchas below before assuming the sysex path runs at all. The sorted param_events queue is attached to the processor via set_param_events(&param_events) before render, so Processor::param_events() exposes the same sample offsets.
  6. Wraps the current main input, optional sidechain, and main output in a stack-owned ProcessBuffers block, then calls the additive processor->process(process_buffers, midi_in, midi_out, ctx) overload. Legacy processors still run through the default projection; processors that override the richer overload can inspect AUv3 bus metadata directly.
  7. Forwards any midi_out events back to the host via self.MIDIOutputEventBlock (AU v3.1+). Each event's sample_offset is added to timestamp->mSampleTime.

The bypass short-circuit (before step 6, when shouldBypassEffect) must NOT memcpy the dry input straight to the output. When the Processor reports a non-zero latency the host has delay-aligned the wet path by that latency (PDC), so a raw dry copy arrives latency samples early — comb-filtering on parallel busses. Route it through boundary::render_bypass_passthrough (adapter_boundary.hpp), sizing the AUBridge::bypass delay line to reported_latency_samples(processor->latency_samples(), host_quirks) in allocateRenderResources. A zero latency collapses to a straight passthrough. The AU v3 render block can't be driven in stock CI (needs a live host), so the bypass logic is covered by the shared test_adapter_boundary_parity.cpp [bypass] fixture the helper is exercised through.

MPE routing

When a Processor declares supports_mpe, AU v3 feeds per-note expression the same way CLAP/VST3 do — via the shared boundary::MpeSidecar (in adapter_boundary.hpp), NOT a hand-rolled tracker. AUBridge owns one boundary::MpeSidecar mpe;; configure() it at init, reserve()+reset() in allocateRenderResources, reset() in deallocateRenderResources, and call mpe.run(*processor, midi_in) in the render block right before processor->process() and after the bypass early-return (a bypassed plugin gets no MPE). AU delivers MIDI already time-ordered, so pass it in host order (no sort) — matching CLAP. Non-MPE plugins get set_mpe_input(nullptr) each block.

The sidecar is unreachable unless the unit says so. Logic (and every other MPE-aware host) decides whether to route an MPE zone's per-member-channel stream from the supportsMPE property; a unit that leaves it unanswered is simply never offered MPE input, however complete its tracker. PulpAudioUnit overrides -supportsMPE to return _bridge.mpe.enabled — the same descriptor opt-in the render block gates on, so what the host is told and what the adapter does cannot drift. The v2 bridge of the same property is kAudioUnitProperty_SupportsMPE; keep both answering or an MPE plug-in works in one adapter and not the other. Unlike bypass, MPE routing IS unit-tested headlessly: the AU v3 MPE case in test_au_plugin_state.mm drives internalRenderBlock with a channel-wide MIDI list and asserts per-note NoteOn/PitchBend/Timbre/Pressure routing.

State: fullState dictionary

fullState wraps store_.serialize() bytes inside an NSData keyed @"pulpState" within the dictionary returned by super.fullState. setFullState: reads @"pulpState" back, calls store_.deserialize. The super call is intentional — AUAudioUnit merges its own internal state (e.g. maximum frames to render) into the dictionary, and the round-trip must preserve it.

Presets: factoryPresets + currentPreset

iOS hosts — AUM, Loopy Pro, Cubasis — drive presets exclusively through this API, so an AUv3 that answers nothing here has no preset UX at all on the platform Pulp ships to first.

factoryPresets builds AUAudioUnitPreset objects from the shared FactoryPresetTable (au_factory_presets.hpp, the same table the three AU v2 adapters serve GetPresets from), bound in initWithComponentDescription:. It returns nil, not an empty array, when the plug-in ships none — an empty array makes a host draw an empty menu. The table is discovered from the loaded bundle, so it is empty in any unit-test binary; a test stages a folder through the Pulp-private pulpFactoryPresetTable accessor.

setCurrentPreset: implements Apple's documented split, and getting it wrong is the trap here:

  • number >= 0 — a factory preset. Load it from the table. It must actually move StateStore values; a host that only sees the name change will look like it is working.
  • number < 0 — a user preset. Ask super's presetStateFor:error: and assign the result to fullState. Never index the factory table with it.

supportsUserPresets therefore returns YES. Pulp owns no writable preset store: userPresets, saveUserPreset:error:, deleteUserPreset:error: and presetStateFor:error: all have AUAudioUnit base-class implementations backed by a location Apple manages, and fullState already round-trips the StateStore plus any processor-owned payload. The two things a plug-in must supply are the YES and the negative-number branch above.

Both branches are wrapped in will/didChangeValueForKey:@"allParameterValues" — that pseudo-property is how AUAudioUnit tells a host every parameter may have moved at once, and currentPreset is one of the changes Apple names for it.

currentPreset's accessors are both overridden, so nothing is synthesized and the ivar must be retained and released in -dealloc (this file is MRR, not ARC). A rejected selection — an out-of-range factory number, an unreadable user preset — must leave both the parameters and the previously reported currentPreset untouched.

ARA companion factory

audioUnitARAFactory is a @property (readonly, nullable) void * — the AU-host-observed KVO property that ARA-aware hosts (Logic Pro 11+) read during scan. It returns pulp::format::ara_companion_factory_for(nullptr), which is non-null in PULP_HAS_ARA builds where a Processor overrode create_ara_document_controller(). Keep this property KVO-visible; it is the AU scan hook ARA-aware hosts inspect.

iOS AUv3 extension

AUv3 on iOS is a UIKit app extension. The view controller (PulpAUViewController in au_view_controller_ios.mm) is AUViewController-derived and builds a ViewBridge — from ViewBridge::Options::hosted_editor(), never a hand-assembled Options; a structural test enforces that every hosted adapter uses the factory — against the extension's loaded AUAudioUnit once KVO fires on self.audioUnit. Extension principal class registration is via NSExtensionMain-style Info.plist — see docs/guides/ios-auv3-guidance.md and the ios skill for the extension target wiring.

iOS AUv3 spawn-chain gotchas

iOS AUv3 was historically "scaffolded but never actually loaded" — the CMake helper and HostApp template both had multiple bugs that silently prevented AVAudioUnit.instantiate from succeeding. The chain that has to be right end-to-end:

  1. .appex binary type must be MH_EXECUTE, not MH_BUNDLE. add_library(... MODULE ...) produces MH_BUNDLE. PluginKit's posix_spawn rejects bundles with ENOEXEC ("Exec format error"), surfaced to the host as OSStatus 4 from AVAudioUnit.instantiate. Fix in tools/cmake/PulpAuv3.cmake _pulp_add_auv3_ios:

    add_executable(${target}_AUv3 ...)
    target_link_options(${target}_AUv3 PRIVATE
        "-e" "_NSExtensionMain" "-fapplication-extension")
    set_target_properties(${target}_AUv3 PROPERTIES
        XCODE_PRODUCT_TYPE "com.apple.product-type.app-extension"
        XCODE_ATTRIBUTE_WRAPPER_EXTENSION "appex"
        BUNDLE TRUE BUNDLE_EXTENSION "appex"
        RUNTIME_OUTPUT_DIRECTORY "...")
    

    Verify with file <appex>/<exec> — must say Mach-O 64-bit executable, NOT Mach-O 64-bit bundle.

  2. HostApp must use .loadOutOfProcess on iOS. The default in-process load is unsupported for AUv3 extensions on iOS; AVAudioUnit.instantiate(with: desc, options: []) returns OSStatus 4. Use .loadOutOfProcess (Apple's "Incorporating Audio Effects and Instruments" sample documents this in a comment).

  3. HostApp's AudioComponentDescription filter must match the extension exactly. The shipped template literally filtered for kAudioUnitType_Effect + subtype Pu_E — would never find any instrument plug-in. Plug-in authors copying the template must update the four-CC values to match their own AUv3's Info.plist AudioComponents entry. Better fix: derive these from AVAudioUnitComponentManager.components(matching:) against a permissive description.

  4. Embedded .appex bundle ID must be a child of the HostApp's bundle ID. Apple enforces parent-child: extension bundle ID must START with the containing app's bundle ID + . + suffix. Otherwise install fails with "Mismatched bundle IDs". The Pulp helper derives the .appex bundle ID from the AUv3 target's BUNDLE_ID — plug-in authors must set that arg to a child of the HostApp's bundle ID, not to a sibling (and not equal to it). On iOS this is now enforced at configure time: pulp_add_ios_host_app (tools/cmake/PulpIosHostApp.cmake) FATAL_ERRORs ("must be nested under") if the extension id is not strictly nested under the host id, so the mistake surfaces before any build instead of at xcrun simctl install. Regression test: test/cmake/test_ios_hostapp_bundle_guard.sh.

  5. HostApp entitlements containing com.apple.security.application-groups require an explicit (non-wildcard) App ID with App Groups capability enabled in Apple Developer. Wildcard App IDs cannot use App Groups. For pure plug-in development testing, strip the entitlement.

  6. Instruments (aumu) need MIDI to make sound. Discovery + load is not enough; the host must call audioUnit.scheduleMIDIEventBlock with a noteOn byte sequence (0x90, <key>, <vel>). Apple's SimplePlayEngine.InstrumentPlayer is the reference. Without this, engine.start() succeeds but the synth sits silently waiting for MIDI input.

  7. Simulator PluginKit caches stale registrations between launches. After a successful INSTANTIATE_OK once, a terminate + relaunch without uninstall + install may flip to INSTANTIATE_ERROR Code=4 because PluginKit's database points at the old install UUID. Real device audio validation is authoritative; Sim is for build/discovery smoke only.

  8. iOS device requires inter-app-audio entitlement on the HostApp for AVAudioUnitComponentManager.components(matching:) to enumerate AUv3 extensions on iOS 11+. Without it, the manager returns an empty match list and your AUv3 appears invisible — even though pkd indexed it correctly and xcrun devicectl device install app succeeded. The iOS Simulator does NOT enforce this, so the missing entitlement is silent until you try a real device. Fix in two places: (a) one-time portal: enable Inter-App Audio on your wildcard com.<you>.pulpdev.* App ID; Xcode auto-fetches the regenerated profile on next build, (b) entitlements file: the shipped templates/ios-auv3/HostApp/Entitlements.plist.in already includes <key>inter-app-audio</key><true/>. Verify with codesign -d --entitlements :- HostApp.app | plutil -p - → "inter-app-audio" => 1. Apple deprecated IAA in iOS 13 (no new IAA-only plug-ins on the Store) but the entitlement still gates AUv3 host scanning — do not strip it. Full setup in docs/guides/ios-dev-signing.md.

iOS AUv3 diagnostic recipe

When the HostApp shows "(no AUv3 found)" or instantiate fails silently:

// Drop these prints into ContentView.discover():
let components = AVAudioUnitComponentManager.shared().components(matching: desc)
print("PULP_DISCOVER: matching=\(components.count) type=\(...) sub=\(...) mfr=\(...)")
let all = AVAudioUnitComponentManager.shared().components(matching: AudioComponentDescription())
print("PULP_DISCOVER_ALL: \(all.count) total")
for c in all where c.manufacturerName == "Pulp" { print("PULP_DISCOVER_ALL_PULP: \(c.name)") }
AVAudioUnit.instantiate(with: desc, options: .loadOutOfProcess) { node, error in
    if let e = error { print("PULP_INSTANTIATE_ERROR: \(e)") }
    guard let node = node else { return }
    print("PULP_INSTANTIATE_OK: \(node.auAudioUnit.componentName ?? "?")")
}

Then launch via XcodeBuildMCP launch_app_sim (returns runtimeLogPath capturing stdout); grep PULP_ <runtimeLogPath> shows the chain.

If matching=0 → check #3 (descriptor mismatch). If matching=N but INSTANTIATE_ERROR Code=4 →

  • Check #1 (file <appex>/<exec> says bundle not executable).
  • Check #2 (options: [] instead of .loadOutOfProcess).
  • Check spawn errors: xcrun simctl spawn booted log show --last 30s --predicate 'eventMessage CONTAINS "PulpSineSynth" AND (eventMessage CONTAINS "Exec format" OR eventMessage CONTAINS "posix_spawn")'.

iOS AUv3 audio validation

Simulator does NOT capture audio in simctl io booted recordVideo (video-only). For audio verification you need either:

  • Real device + headphones / mic capture
  • Sim audio loopback via a Mac audio routing tool (BlackHole, Loopback)
  • Or accept that "PULP_INSTANTIATE_OK + PULP_NOTE: ON + engine.start succeeded" proves the wiring; trust the synth code path that's already tested at the unit level

Two CMake entry points: keep signatures in lockstep

pulp_add_plugin(...) (the general entry) and pulp_add_ios_auv3(...) (the iOS-extension wrapper) both end up calling the internal _pulp_add_auv3(target name bundle_id version manufacturer category plugin_code manufacturer_code accepts_midi) helper with positional arguments. When you add or remove an arg on _pulp_add_auv3, you must update BOTH wrappers — a missed update on the iOS wrapper surfaces as:

CMake Error at tools/cmake/PulpUtils.cmake:<line> (_pulp_add_auv3):
  _pulp_add_auv3 Function invoked with incorrect arguments

only on the iOS toolchain configure, because the other leg (pulp_add_plugin) never exercises the wrapper. Adding ACCEPTS_MIDI to _pulp_add_auv3 without threading it through pulp_add_ios_auv3 is the representative failure mode; the Coverage-macOS lane catches this class of mismatch because it configures the iOS wrapper path.

macOS AU v3 packaging — framework + stub .appex + container .app

Apple's architecture, not a Pulp invention. Get this wrong and you will spend an entire session debugging silent Pluginkit rejections.

Apple's "Creating custom audio effects" sample doc states verbatim:

"Your extension's main binary cannot be dynamically loaded into another app, which means all executable AU code must reside in a separate framework bundle. The extension target still needs to contain at least one source file for the extension binary to be created, properly loaded, and linked with the framework bundle."

That constraint is what forces the 3-tier shape. Pulp's macOS AU v3 lane (tools/cmake/PulpAuv3.cmake's _pulp_add_auv3_macos_* helpers, on the macOS framework path) implements it:

ChainerSynth.app/                                 ← container .app
├── Contents/
│   ├── MacOS/ChainerSynth                        ← tiny Cocoa shell, launched once to register
│   ├── PlugIns/
│   │   └── ChainerSynth.appex/                   ← stub .appex (NSExtensionMain entry)
│   │       └── Contents/
│   │           ├── Info.plist
│   │           │   • NSExtensionPointIdentifier = com.apple.AudioUnit-UI
│   │           │   • NSExtensionPrincipalClass = PulpAUMacViewController
│   │           │   • NSExtensionAttributes.AudioComponentBundle =
│   │           │       <bundle-id>.AUv3Framework    ← MUST match framework's CFBundleIdentifier
│   │           └── MacOS/ChainerSynth            ← ~50KB stub binary, links framework
│   └── Frameworks/
│       └── ChainerSynthAUv3Framework.framework/  ← REAL code lives here
│           ├── Info.plist  (CFBundlePackageType=FMWK)
│           └── Versions/A/
│               ├── ChainerSynthAUv3Framework     ← contains PulpAudioUnit + PulpAUMacViewController
│               └── libwgpu_native.dylib          ← any embedded dylibs

iOS is different — iOS AU v3 still uses the monolithic .appex. The framework split is macOS-specific because of Apple's loadInProcess out-of-process requirement on macOS. pulp_add_ios_auv3() stays on the legacy monolithic path; pulp_add_plugin(FORMATS AUv3) dispatches to the macOS framework path on macOS.

What goes in the framework vs the .appex stub

  • Framework (_pulp_add_auv3_macos_framework): the per-plugin Core OBJECT lib + au_adapter.mm (PulpAudioUnit) + au_view_controller_mac.mm (PulpAUMacViewController + AUAudioUnitFactory) + per-plugin au_v3_entry.cpp (the PULP_AUV3_PLUGIN macro that registers the processor factory).
  • Stub .appex (_pulp_add_auv3_macos_appex): a generated 1-function .mm source — void Pulp_<plugin>_AUv3_keep_alive(void). Entry point is Apple-provided _NSExtensionMain; we pass -e _NSExtensionMain and -fapplication-extension. The stub links the framework with -Wl,-force_load,$<TARGET_FILE:framework> so its Obj-C classes register with the runtime — without -force_load, NSClassFromString(@"PulpAUMacViewController") returns nil and the host fails to instantiate the AU.
  • Container .app (_pulp_add_auv3_macos_host): tiny Cocoa shell with a "this is the registration host" placeholder window. Bundle ID <plugin-bundle-id>.AUv3Host. The user runs it once after install to trigger Launch Services scan.

pulp-build-info.json (the build-identity record every bundle carries; see the ship skill) goes into the .appex (format AUv3) and the container .app (format AUv3Host), never the framework: _pulp_attach_build_info skips FRAMEWORK targets because a Contents/Resources directory at a framework root is an unsealed-root codesign failure. pulp_add_ios_auv3() bypasses pulp_add_plugin(), so it attaches the record itself — keep that call when editing either entry point.

Do NOT put au_entry.mm's PulpAUFactoryObj (legacy AudioComponentRegister factory C function) anywhere in the macOS AU v3 lane. The macOS path uses _NSExtensionMain + NSExtensionPrincipalClass to find the factory class.

rpath: 4 levels up, not 2

The .appex's binary at MyApp.app/Contents/PlugIns/MyApp.appex/Contents/MacOS/MyApp needs to find the framework at MyApp.app/Contents/Frameworks/. From the binary, that's 4 parent dirs up (MacOS → Contents → MyApp.appex → PlugIns → Contents → Frameworks):

set_target_properties(${appex_target} PROPERTIES
    INSTALL_RPATH "@executable_path/../../../../Frameworks")

@executable_path/../../Frameworks is the tempting wrong answer — it suits iOS's flat .appex layout, but breaks on macOS, where the .appex has its own Contents/MacOS/ and sits one bundle deeper. An rpath recipe carried over from an iOS lane will resolve inside the .appex and the framework silently fails to load.

The container .app's binary at MyApp.app/Contents/MacOS/MyApp needs 2 parent dirs up: INSTALL_RPATH "@executable_path/../Frameworks".

Signing + notarization is mandatory on Sequoia/Tahoe

macOS Tahoe's Pluginkit silently rejects ad-hoc-signed, Developer-ID- signed-without-notarization, and even properly Developer-ID-signed but unnotarized AU v3 .appex bundles. pluginkit -mAvvv -p com.apple.AudioUnit-UI returns "no matches" with zero log diagnostics. The only signal you get is the absence of the plugin.

You MUST:

  1. Sign embedded dylibs first (libwgpu_native.dylib, etc.) with --timestamp --options runtime and the same Developer ID identity
  2. Sign the framework
  3. Sign the .appex with --entitlements <sandbox>.plist (the com.apple.security.app-sandbox entitlement is REQUIRED for app extensions; without it pkd logs "plug-ins must be sandboxed" and rejects). Plus allow-jit + allow-unsigned-executable-memory + disable-library-validation for JS-engine + Skia/Dawn editors.
  4. Sign the container .app (also with hardened-runtime entitlements for library validation)
  5. Notarize the container .app via xcrun notarytool submit --apple-id <id> --team-id <team> --password <app-specific-pwd> --wait
  6. Staple with xcrun stapler staple
  7. Install to /Applications and lsregister -f -R
  8. Open the container .app once to trigger Launch Services scan → Pluginkit then registers the embedded extension

The full recipe is in tools/scripts/sign-notarize-auv3-mac.sh.

For the reusable dev-signing cred layout that step 5 consumes (PULP_TEAM_ID / PULP_NOTARY_*), see docs/guides/ios-dev-signing.md — 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.

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;
    }
    // ... AppKit work
}

Don't just guard at setAudioUnit: (the property setter). The compiler can inline through the property setter when createAudioUnit assigns to self.audioUnit, bypassing your thread check. The hard guard inside rebuildEditorIfReady is the only safe place. Same gotcha on iOS — au_view_controller_ios.mm has the same guard.

dealloc is on the XPC queue too — reset the host on main. The same off-main hazard bites teardown: a GPU-backed PluginViewHost's CVDisplayLink idle pump is dispatched to the main queue and dereferences the ViewBridge, so if the last controller release lands on the XPC queue, freeing host + bridge off-main races a queued main-queue idle block → SIGSEGV in display_link_callback (Ableton Live "add plugin then delete" repro). Both au_view_controller_mac.mm and au_view_controller_ios.mm dealloc now reset _viewHost via [NSThread isMainThread] ? reset : dispatch_sync(main, reset) before the reverse-order ivar destruction (flips the host liveness token + stops the link first). Do NOT instead clear idle_callback_ from the off-main dealloc — that just swaps in a data race on the std::function. See the view-bridge skill, "AU v3 teardown must ALSO run on the main thread," for the full lifecycle.

Logic's per-plugin failed-state cache

Logic Pro remembers AU v3 plugins that previously failed to validate in ~/Library/Preferences/com.apple.logic10.plist under audioUnitConfig.<type>-<subtype>-<manufacturer>. Working entries are populated dicts; failed entries are <dict/> (empty). Logic will not re-attempt loading an empty-dict entry on relaunch — even after you've fixed the bug and reinstalled, Logic refuses to list the plugin until you delete that entry.

Recovery without a full AU rescan:

# Logic Pro must be QUIT first
killall -9 cfprefsd

# Edit on disk while cfprefsd is dead so it reads fresh on next access
plutil -convert xml1 -o /tmp/logic10.xml ~/Library/Preferences/com.apple.logic10.plist
sed -i.bak '/<key>aumu-Chnr-Pulp<\/key>/{N;d;}' /tmp/logic10.xml
plutil -convert binary1 -o ~/Library/Preferences/com.apple.logic10.plist /tmp/logic10.xml
killall -9 cfprefsd AudioComponentRegistrar pkd
rm -f ~/Library/Caches/AudioUnitCache/com.apple.audiounits.cache \
      ~/Library/Caches/AudioUnitCache/com.apple.audiounits.sandboxed.cache

# Now launch Logic — it'll incrementally rescan (NOT a full scan) and
# pick up the fresh registration from AudioComponentRegistrar.

PlistBuddy does NOT work for editing this — it talks to cfprefsd which serves a cached in-memory view of the plist. Edit the XML directly while cfprefsd is killed.

AU v3 view configuration plumbing

PulpAUMacViewController + PulpAUViewController implement AUAudioUnitFactory, open ViewBridge against the AU's real pulpProcessor + pulpStore, build PluginViewHost via decide_gpu_host, and (on macOS) call set_design_viewport(w, h) + set_fixed_aspect_ratio(w/h) so the editor paints at design size and host-driven window resize is letterboxed proportionally. iOS differs: au_view_controller_ios.mm deliberately does NOT force a design viewport — it lays the root out at the actual pane bounds so a responsive flex scene fills edge-to-edge (aspect-locked scaling left dark letterbox bars on the sides and pushed header text to the edge). A fixed-aspect iOS editor that needs letterboxing can call set_design_viewport itself; see the ios skill.

Editor-initiated resize requests on iOS are advisory. Install the Processor::request_editor_resize handler only after the bridge and host are attached but before ViewBridge::notify_attached() invokes Processor::on_view_opened (which may synchronously restore a mode-specific size), require the main thread, validate the request through ViewBridge, and publish the accepted size through preferredContentSize. Do not pin the design viewport or replace the normal pane-bounds layout: the AU host remains authoritative and the editor stays responsive. Register the handler under the view controller's owner key. Clear that owner before swapping the audio unit while keeping the old unit alive until main-thread cleanup, before rebuilding/tearing down the editor, and in dealloc; otherwise a late request can call through a stale controller or host. The XPC-queue setter must not read or write the raw cached processor; that state is main-thread-owned. Captures must remain unretained under both ARC and MRC, and the ARC dealloc path must clear the handler too. In macOS teardown, check isViewLoaded before reading self.view so deallocation cannot lazily construct a new editor.

On macOS, the view controller's root view must be created at the compile-time design size when PULP_PLUGIN_DESIGN_W/H are available. REAPER can choose the initial AUv3 container from loadView / viewDidLoad before createAudioUnit provides the processor; falling back to 400x300 there opens imported/scripted UIs in a small padded window even though the later ViewBridge reports the correct design size.

REAPER can also shrink the controller view after the first editor build on its in-process AUv3 path. The macOS controller has a one-shot initial size sync after attaching PluginViewHost: if the first live layout is smaller than the design viewport, it re-applies preferredContentSize, expands the host window by the exact view delta, and resizes the root view to the design. Keep this limited to initial attach; manual host resize must continue through viewDidLayout without being forced back.

Do NOT implement supportedViewConfigurations: / selectViewConfiguration: for fixed-design editors. Verified in Logic; this replaced the earlier "accept a large-enough config" policy, which caused the bug. Logic Pro sizes AU v3 editors through the view-configuration path and offers ONLY oversized ~4:3 configs (measured: 1024x768 / 1366x1024). The moment the AU returns any supported config, Logic locks the editor window to that config's aspect ratio at every size — so a wide fixed design (e.g. 900x520 ≈ 16:9.4) letterboxes with top/bottom bars that cannot be resized away (confirmed by AX resize probing: grow/shrink all snap back to 4:3). Apple's CoreAudioKit header states an empty index set means "use the largest available view configuration," so returning empty makes Logic pick its largest 4:3 config — strictly worse.

Base AUAudioUnit already implements these selectors. By NOT overriding them, Logic falls back to the plain view at preferredContentSize and lets the window free-resize to the design's own aspect — tight and proportional, matching REAPER (in-process, honors design size), CLAP (gui_get_size), VST3 (getSize), and standalone. REAPER never used these selectors, so removing them is a no-op there. Regression-guarded by test_au_plugin_state.mm → "AU v3 does not opt into host view configurations" (asserts PulpAudioUnit's IMP for both selectors equals the inherited base IMP — i.e. not overridden).

If a future fluid/multi-config editor genuinely wants host view configurations, reintroduce the selectors gated on that editor kind, but keep them OFF for any set_design_viewport / set_fixed_aspect_ratio (fixed-design) editor.

Logic OOP first-paint clip — defer GPU-host creation until a real size

After the view-config fix above, Logic stopped letterboxing but the editor's first paint still clipped: the UI rendered at the design size inside Logic's restored (smaller) window — top + right cut off — until a manual resize or a window close+reopen snapped it tight. Reopen worked (the container already existed at the right size); only the very first open raced.

Root cause (verified in Logic): Logic hosts AU v3 out-of-process and does not push its restored window size to the extension's view on initial open — not via viewDidLayout, not via setFrameSize:, not via a self.view.bounds change during a ~0.5s poll. It embeds our view, leaves it at the design-size frame from loadView, and composites that oversized layer into its smaller window. The GPU surface (created at attach with opts.size = design) therefore paints once at design size and the stale frame persists until the host next requests a redraw (a resize or reopen). Forcing a repaint, re-asserting preferredContentSize, or forcing the host window size did not fix it (and forcing Logic's window is host-hostile — it fights Logic's restore).

Fix (the one that worked) — defer creating the PluginViewHost / GPU surface until the root view reports a real, settled host size, so the surface is never born at the design size for a smaller first window. Mechanics in au_view_controller_mac.mm:

  1. loadView makes the root view a PulpAUMacRootView (NSView subclass) overriding setFrameSize: → an onResize block; in viewDidMoveToSuperview it fills its superview the frame-based way (autoresizingMask = width|height-sizable + frame = superview.bounds) so AppKit sizes it to the host container on embed and every host resize. ⚠️ Do NOT use Auto Layout (translatesAutoresizingMaskIntoConstraints=NO + edge constraints) to pin it — that CRASHED Ableton Live: when the host places the AU window, our setFrameSize: → [super] → setNeedsLayout engages the constraint engine (-[NSWindow _postWindowNeedsLayout]), which throws in that context and the uncaught exception kills the host. Frame-based autoresizing fills the container identically for our purposes (the deferred GPU host reads the real bounds) without touching the constraint engine, and is compatible with frame-driven AU hosts (Live, REAPER, Logic). Lesson: in an AU editor view embedded by an arbitrary host, never engage Auto Layout against the host's window.
  2. rebuildEditorIfReady opens the ViewBridge, sets preferredContentSize, wires onResize, sets _viewHostPending + _pendingRoot, and does not create the host or force the view frame.
  3. -createViewHostIfReady builds the host at the view's real bounds (opts.size = bounds, not design), then set_design_viewport(design). While bounds still equal the design size it waits (up to the kInitialSizeSyncMaxAttempts × kInitialSizeSyncIntervalMs settle window) for the host's likely-different restored size; after that it accepts the current size. It is driven from onResize, viewDidLayout, and the runInitialSizeSync fallback (all idempotent).
  4. Clear onResize first in dealloc (it captures self unretained + touches _viewHost, which is destroyed after [super dealloc]).

Trade-off: a brief (<0.5s) black frame on first open while we wait for the real size — acceptable, and far better than a clipped first paint. Never create the AU v3 GPU surface at the design size before the host has sized the view. When working on Dawn/Skia-backed editors in any out-of-process host, treat "first-paint size" as a first-class concern: confirm what size the host actually delivers and when, rather than assuming viewDidLayout/preferredContentSize will be honored on initial open.

REAPER (etc.) FX-chain letterbox — top-align the design viewport

A second AU sizing asymmetry vs CLAP/VST3: when a host gives the AU a pane TALLER than the design aspect (e.g. REAPER's FX-chain pane — AU has no host resize-negotiation like CLAP's gui_adjust_size / VST3's checkSizeConstraint, so the host picks the pane aspect), the centered design floated between top+bottom bands while CLAP/VST3 (aspect-constrained → no slack) sat content-at-top. Fix: the AU controller calls PluginViewHost::set_design_viewport_top_align(true) → compute_design_viewport_transform(..., top_align=true) anchors the design to the TOP (slack becomes a single bottom strip), reading like CLAP/VST3. It is only the AU path + only visible when there is vertical slack (no-op when the pane is design-aspect), and the SAME transform feeds paint AND input mapping (window_to_root_point) so clicks stay aligned. True pixel-fill parity is not achievable (AU can't negotiate the pane aspect). Verified in REAPER.

The PULP_AUV3_PLUGIN() macro replaces hardcoded force_link

Before the per-plugin AU v3 entry macro, au_entry.mm called pulp_gain_force_link() to force the linker to retain pulp-gain-specific static initializers. This broke AU v3 for every plugin OTHER than pulp-gain. The current adapter ships <pulp/format/au_v3_entry.hpp> with PULP_AUV3_PLUGIN(factory_fn) — place it in ONE .cpp per plugin (convention: au_v3_entry.cpp in the plugin's source dir). The CMake helper auto-discovers and links it into the framework. Mirrors PULP_CLAP_PLUGIN and PULP_AU_INSTRUMENT.

Gotchas

AURenderEventMIDIEventList = UMP — not short MIDI, not raw sysex

AU v3.1+ delivers long MIDI and MIDI 2.0 messages through AURenderEventMIDIEventList, which carries a MIDIEventList of MIDIEventPacket structs — UMP-encoded 32-bit words. Sysex7 arrives as type-3 UMP messages spread across 2-word packets with a 4-bit status field in bits 20–23 of word 0:

status == 0x0  → complete single-packet sysex
status == 0x1  → start (reset accumulator)
status == 0x2  → continue
status == 0x3  → end

The sysex7 reassembly state machine no longer lives inline in au_adapter.mm — it delegates to the shared pulp::midi::UmpSysex7Reassembler (core/midi/include/pulp/midi/ump_sysex7_reassembler.hpp) so the same battle-tested implementation backs every UMP-aware Pulp backend (AUv3, CoreMIDI device input, and any future Win/Linux UMP path). au_adapter.mm only owns the AURenderEventMIDIEventList walk, the word-cursor advance, and the per-MIDIEventList EmitCtx that tags the assembled sysex with event->head.eventSampleTime.

When touching the AUv3 sysex path: prefer fixes inside the shared reassembler (and test/test_ump_sysex7_reassembler.cpp) over adapter-local patches. Two critical invariants the adapter still owns itself remain unchanged and important:

  1. Advance the word cursor by ump_words, not by 1. A type-3 message is 2 UMP words long; advancing by 1 makes the second word's header nibble look like a new message header. This lives in the switch (mt) block above the call to reassembler.feed_packet.
  2. reassembler.feed_packet expects an already-type-3 packet — the adapter checks mt == 0x3 before calling. Don't push the type check into the reassembler; both call sites already need the nibble for cursor advance and re-checking would be redundant in the hot path. The same visitor now also handles types 0x1 (system), 0x2 (MIDI 1.0 channel voice) and 0x4 (MIDI 2.0 channel voice); anything else is skipped by its true word length. Do not narrow it back to sysex-only — that is what silently discarded every note a MIDI 2.0 host delivered.

Sysex7 size is still 0..6 bytes per 2-word packet; preserve message boundaries. The reassembler clamps to 6 defensively.

Both invariants are tested by test/test_ump_sysex7_reassembler.cpp (the regression test feeds a contrived packet whose word1 begins with a nibble matching sysex7 to prove word1 is never reparsed as a fresh word0). Touch the reassembler → add a test that exercises the boundary.

MIDI protocol negotiation — an AU that says nothing gets 7-bit MIDI

AUAudioUnit.h is explicit: an AU that does not override AudioUnitMIDIProtocol "will default to receiving legacy MIDI". That is not a transport detail — the host performs the narrowing, so 16-bit velocity and 32-bit controller values are already 7-bit by the time the render block runs. No amount of UMP machinery downstream can recover them.

PulpAudioUnit overrides it:

- (MIDIProtocolID)AudioUnitMIDIProtocol {
    if (_bridge.ump_enabled) return kMIDIProtocol_2_0;
    return [super AudioUnitMIDIProtocol];
}

ump_enabled is latched once at init from descriptor().effective_capabilities().supports_ump. Three consequences worth knowing before touching this:

  1. It decides whether AURenderEventMIDIEventList arrives at all. A host delivers event lists only to a unit that negotiated a protocol. Before this override existed the whole event-list branch — sysex7 reassembly included — was code no conforming host reached.
  2. Negotiating without decoding channel voice loses notes. Once the unit says MIDI 2.0, the host routes everything through event lists as type 0x4. The walk must decode 0x2/0x4, not just 0x3.
  3. Publish both transports. The render block appends channel-voice packets to AUBridge::ump_buffer (pre-reserved + capacity-limited, published via set_ump_input() for the duration of process()) and projects each to MIDI 1.0 into midi_in via ump_to_midi1_event. The projection is what keeps every existing MidiBuffer reader — and the MPE sidecar, which runs off midi_in — working under MIDI 2.0. Packets with no MIDI 1.0 form (per-note pitch bend, per-note CC) reach an opted-in Processor through the UMP buffer only. set_ump_input(nullptr) for a Processor that did not opt in, so its behaviour is byte-for-byte what it was.

Gate every new behaviour here on the descriptor flag. A plug-in that does not declare supports_ump must negotiate nothing and see exactly today's stream.

Coverage: test_au_plugin_state.mm drives internalRenderBlock with a real MIDIEventList of type-0x4 messages and asserts a 16-bit velocity survives intact, that per-note pitch bend arrives (it has no MIDI 1.0 form, so it proves native delivery rather than a round trip), and that a non-declaring plug-in still reports a non-2.0 protocol, gets a null ump_input(), and keeps its notes.

AUv3 MIDI output is still short-message only

MIDIOutputEventBlock is packed from uint8_t bytes[3], so a Processor's output sysex does not reach an AUv3 host — while the AU v2 adapter carries it (MidiOutputPacketBuilder in au_v2_common.hpp). Closing the gap means MIDIOutputEventListBlock plus an outbound UMP sysex7 packetizer, which core/midi does not have (it has a reassembler, the inbound half only). Don't "fix" it by truncating; it is an unimplemented path, not a bug in this one.

Short-MIDI length must be validated

AURenderEventMIDI.length is the length in bytes. Short messages are 1..3 bytes and data[0]'s MSB must be set (status byte). The adapter explicitly rejects length == 0, length > 3, and messages with (data[0] & 0x80) == 0. Do not relax that gate — corrupt short messages past the gate feed choc::midi::ShortMessage garbage.

_bridge captured as raw pointer in the render block

The render block captures &_bridge (a C++ struct inside the Obj-C class) as a raw pointer. ARC does not retain _bridge. Keeping the audio unit alive is the host's job; the block lives for the audio unit's lifetime. Do not capture self into the render block — that creates a retain cycle that only breaks when the host drops the unit, and Logic will reproduce-steps that via preset hot-swap.

The MIDI-out fan-out in the same block does capture self.MIDIOutputEventBlock via ARC (__block id style through the implicit-self path). That one is intentional — the block the host installs is ARC-retained on the audio unit and does not form a cycle.

allocateRenderResourcesAndReturnError is where prepare() lives

Not in initWithComponentDescription:. The host may instantiate the audio unit to enumerate parameters / buses without ever rendering; calling Processor::prepare() before the host has a sample rate + max frames in hand wastes work and can mis-size buffers. Mirror: deallocateRenderResources calls processor->release().

renderContextObserver is the parallel-renderer workgroup handoff

PulpAudioUnit always returns an AURenderContextObserver block. At init it checks whether the Processor also implements the separate format::AudioWorkgroupClient capability (kept out of the Processor vtable for node-ABI stability). The observer atomically forwards AudioUnitRenderContext::workgroup, including a null context/workgroup for removal. Apple invokes the block on the realtime render thread immediately before a changed-context render and requires the plug-in to prepare auxiliary threads to leave the preceding workgroup and join the new one. The adapter therefore publishes a generation and completes the worker pool's full-participant acknowledgment barrier before returning. The barrier is allocation-free and lock-free; do not replace it with a mutex, condition variable, scheduler yield, thread join, or reference-count operation. A failed non-null join is acknowledged as a completed leave of the old context and keeps the renderer inline for the new publication.

Do not retain the borrowed OS handle in the adapter or call os_workgroup_join from the observer on behalf of another thread. A SequenceProcessor opts in by deriving from AudioWorkgroupClient; ordinary processors pay only the cached null capability pointer and the observer is a no-op.

deallocateRenderResources runs off the realtime path. It publishes explicit render-context removal and waits for every auxiliary worker to leave before calling Processor::release(), so a persistent graph pool cannot remain joined to a host-owned workgroup after its render resources are gone.

tailTime is in seconds, not samples

Pulp's descriptor().tail_samples is an integer sample count; tailTime returns seconds. < 0 means infinite and returns std::numeric_limits<double>::infinity() (AU's sentinel). Do not return 0 — a 0 tail tells the host "this plugin emits nothing after input stops" and delay/reverb tails get chopped.

Bypass routing — auto-detected Bypass parameter

initialize auto-detects the plugin's bypass parameter via the shared pulp::state::is_bypass_param contract and routes both AU v3 bypass surfaces (the host's bypass AUValue and the plugin's automation lane) through the same StateStore atomic so they stay in lockstep (DAW quirks row 21). Param designation: a Processor can declare ParamInfo::designation = ParamDesignation::Bypass to mark its bypass control independent of name; the legacy boolean-"Bypass" name/range heuristic is the fallback for params that declare none, so existing plugins are unchanged. When no bypass param exists the bridge falls back to a local atomic so the contract still holds for plugins that don't declare one.

Trigger params: the bridge calls bridge->store.reset_triggers_rt() to auto-reset trigger / momentary params (ParamInfo::is_trigger, or a ParamDesignation::Reset "reset/panic" control) back to their default as a single-exit invariant — both after Processor::process on the normal path AND before the bypass short-circuit's return noErr, so a panic/reset raised while bypassed clears this block instead of the next active one. The retained AUParameterTree's implementorValueProvider reads the store live (store.get_value(param.address)), so the host reflects the settled value on its next poll/query of the parameter. Note there is no KVO push on the reset — AUParameter.value (the cached property) is not actively notified; AU hosts re-query through the value provider, which is the same "reflect on next read" contract VST3/CLAP have outside their output-event scan. If a future host needs an active push, add an AU-safe deferred main-thread notify keyed on reset trigger params.

internalRenderBlock short-circuits to pass-through audio when bypassed (in→out for effects, silence for instruments) and never calls Processor::process. MIDI output stays empty so bypassed MIDI FX don't leak notes. Diagnostic: read pulpBypassParameterId on PulpAudioUnit (also exposed from the shared au_audio_unit.h header) to confirm which ParamID got picked up.

Offline-render routing — renderingOffline → ProcessMode::Offline

A host doing a faster-than-real-time bounce sets AUAudioUnit.renderingOffline = YES before rendering and back to NO afterward. The adapter mirrors the bypass pattern: setRenderingOffline: calls super and stores the flag in a bridge-local atomic (rendering_offline), and the render block reads it (acquire) to set ctx.process_mode = Offline / render_speed_hint = FasterThanRealtime. This lets a processor switch to an offline-only code path — e.g. an async GPU engine that, in realtime, hands the audio thread a worker-produced result and drops to silence on a miss; an offline render runs faster than the wall-clock worker, so without this hook every block misses and the wet (reverb/delay tail) is dropped from the bounce. The processor's offline path should drive that work synchronously (blocking readback is fine offline — no RT deadline). Trust model is the same as bypass: the host is expected to clear renderingOffline when returning to live playback; a host that leaves it set would keep the processor on the (blocking) offline path during realtime. VST3 surfaces this via ProcessSetup.processMode == kOffline, AU v2 via kAudioUnitProperty_OfflineRender, CLAP via clap.render, and AAX for AudioSuite instances (see the auv2 / clap / aax skills).

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:

  1. ~PluginViewHost runs FIRST. It calls root_.set_plugin_view_host(nullptr) (and set_frame_clock(nullptr) on the GPU host). root_ references either _bridge->view_ OR _fallbackView — BOTH are still alive at this point, so clearing the back-pointers is safe on either path.
  2. ~View (_fallbackView) runs next — no-op on the bridge-success path; on the fallback path the back-pointer was cleared in step 1.
  3. ~ViewBridge runs last. Its destructor calls close() → Processor::on_view_closed → view_.reset(). The back-pointer was already cleared in step 1.

_viewHost MUST be declared last (destroy first). The original order _bridge, _viewHost, _fallbackView destroyed _fallbackView before _viewHost; on the no-audioUnit preview path _fallbackView is the View _viewHost->root_ references, so the host cleared a back-pointer into a freed View.

Calling _bridge->close() HERE explicitly (before [super dealloc]) reverses that order: the View dies first, then ~PluginViewHost dereferences a dangling root_ reference and crashes AUv3 editor close. An earlier variant explicitly closed the bridge here and shipped that crash; the fix is to remove the explicit close, NOT to add it.

The AUv3 editor now also auto-selects the GPU host via the shared decide_gpu_host() helper (Options overload) — see the view-bridge skill's "GPU view host auto-selection" section.

Build the host's PluginViewHost::Options with editor_host_options(bridge, gpu, size) (gpu_host_select.hpp), never field by field: it carries the plug-in's declared background (ViewBridge::editor_background_rgb()), which the host paints on its backing layer and under the tree whenever there is no document frame. A hand-built Options silently drops it and this format opens on the framework navy while the others open on the plug-in's colour (view-bridge, "The first frame must already look like the plug-in"). Both view controllers keep the bridge-less preview/fallback path on default Options; only the bridged path goes through the helper, and it passes the controller's real host size back in as size. Once the deferred host attaches (mac -createViewHostIfReady, iOS view load), both controllers call ViewBridge::prepare_first_frame(*_viewHost): the document mounts and its first frame is presented at that settled size with its Core Animation transaction, so the host never composites the empty editor (view-bridge, "Editor open"). The controller's own view is still empty until the deferred attach; that wait is the size negotiation, not the document.

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         # 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.

Packaging — macOS appex + iOS device + Simulator

The AU v3 packaging shape is three distinct targets, dispatched by _pulp_add_auv3 in tools/cmake/PulpAuv3.cmake:

  1. macOS — framework-inside-containing-app: ${target}_AUv3Framework (SHARED FRAMEWORK with the AU code), ${target}_AUv3 (stub .appex linking the framework via AudioComponentBundle), ${target}_AUv3Host (containing .app with both embedded under Contents/Frameworks + Contents/PlugIns).
  2. iOS device — single monolithic .appex produced by _pulp_add_auv3_ios; signed with the templates/auv3/iOS-Device-Entitlements.plist.template entitlements (application-groups).
  3. iOS Simulator — same _pulp_add_auv3_ios path, but configure picks iOS-Simulator-Entitlements.plist.template instead. CMake detects the Simulator via CMAKE_OSX_SYSROOT matching Simulator|iphonesimulator. Mac Catalyst is deliberately out of scope for the current AU v3 lane.

Xcode-project generation: pulp ship auv3-xcodeproj

Once pulp_add_plugin(... FORMATS AUv3) is wired, the developer flow for iterating on the AUv3 target in Xcode (instruments, debugger, simulator profiles) is:

pulp ship auv3-xcodeproj <target>                    # iphonesimulator (default)
pulp ship auv3-xcodeproj <target> --sdk iphoneos     # device
pulp ship auv3-xcodeproj <target> --sdk macosx       # macOS lane
pulp ship auv3-xcodeproj <target> --output build/xcode/MyPlugin
pulp ship auv3-xcodeproj <target> --open             # open in Xcode after gen
pulp ship auv3-xcodeproj <target> --dry-run          # print cmake invocation + build hint

The wrapper runs cmake -G Xcode against a separate build dir (default build/xcode/<target>-<sdk>) so it doesn't collide with the user's normal Ninja/Makefile cache. The generated build hint selects <target>_AUv3; CMake configure still generates the project normally. iOS SDKs pull in tools/cmake/ios.toolchain.cmake with the correct IOS_PLATFORM (OS for device, SIMULATOR64 for simulator). On macOS, the generated project also includes <target>_AUv3Host, the runnable containing app. This is the supported Xcode iteration path for AU v3 targets.

Install + cache-clear gotcha

pulp-install-${target} for AUv3 copies the containing .app to ~/Applications/<name>.app, then runs:

/usr/bin/pluginkit -a "<app>/Contents/PlugIns/<name>.appex"
/usr/bin/killall -9 AudioComponentRegistrar  # may be a no-op if it isn't running

The pluginkit -a registration is what makes Launch Services + the AU host's AVAudioUnitComponentManager discover the extension on next relaunch. The killall step flushes the AudioComponent cache so the DAW sees the new component without a full logout. Both steps are documented in pulp doctor --au-cache; the install target wires them automatically.

~/Library/Audio/Plug-Ins/Components/ is AU v2 only — AU v3 hosts discover extensions through PlugInKit, not the v2 component directory. Don't try to install an AU v3 .appex there.

Cross-references

  • .agents/skills/ios/SKILL.md — iOS extension wiring, simulator deploy, audio session handling.
  • .agents/skills/view-bridge/SKILL.md — editor contract. On iOS, au_view_controller_ios.mm is the canonical AUv3 example of the protocol.
  • .agents/skills/auv2/SKILL.md — the AU v2 adapter, separate bundle.
  • .agents/skills/ara/SKILL.md — audioUnitARAFactory KVO property.
  • .agents/skills/mpe/SKILL.md — MPE sidecar contract (AU v3 delivers MPE as short MIDI via AURenderEventMIDI; the Pulp path is the same MpeVoiceTracker as CLAP / VST3).
  • .agents/skills/clap/SKILL.md and .agents/skills/vst3/SKILL.md — cross-format parity sanity-check for host-specific regressions.

iOS AUv3 controller hands GpuSurface to ScriptedUiSession

au_view_controller_ios.mm and au_view_controller_mac.mm both now call, immediately after PluginViewHost::create():

if (auto* scripted = _bridge->scripted_ui()) {
    scripted->attach_gpu_surface(_viewHost->gpu_surface());
}

This routes the JS-side navigator.gpu / canvas.getContext('webgpu') shim through the host's live Dawn surface. Skip it and any embedded WebGPU JS content (Three.js, raw WebGPU) renders black with no error — the shim silently falls through to mocks. Verify with the log line [plugin-gpu-host] GpuSurface attached to WidgetBridge via ScriptedUiSession (iOS AUv3).

Full cross-platform contract lives in the view-bridge skill's "GpuSurface plumbing into WidgetBridge" section.

Updated (WAH-1): subscribe, do not sample. The one-shot attach_gpu_surface(host->gpu_surface()) read this section used to describe is GONE. It only worked on hosts that build their surface in the constructor; the Windows host creates its Dawn surface inside attach_to_parent(), so the read returned nullptr forever and every Windows editor fell back to mock WebGPU. Adapters now call the shared helper once:

gpu_surface_binding_ = bind_gpu_surface(*host, bridge->scripted_ui(),
                                        gpu_decision, "mac AUv3 / iOS AUv3");

It follows PluginViewHost::observe_gpu_surface(), forwards creation AND teardown into the session, and owns the CPU-fallback diagnostic (which no longer fires on a pre-attach pending state). Reset the returned subscription in the editor-close path, before the bridge that owns the session is destroyed.

  • docs/guides/ios-auv3-guidance.md — the human-facing iOS AUv3 guide.
  • docs/guides/formats.md — user-facing format overview + auval recipes.
  • Cross-format sysex sidecar note: AU, VST3, CLAP, and AAX sysex handling all share the same sidecar shape. Fixing one means checking the other three.

Host-quirks consumption

This adapter consumes the host-quirks ledger at init: it caches resolved_quirks(detect_host_info().type, version) once (the runtime policy — PULP_HOST_QUIRKS env / set_host_quirk_policy() API / compile default — applies via resolved_quirks()), then gates DAW accommodations on those flags instead of hardcoding them.

First wired flag: clamp_latency_to_nonneg. Latency reporting routes through the pure helper pulp::format::reported_latency_samples(raw, quirks) (in host_quirks.hpp): a negative latency_samples() clamps to 0 when the quirk is enforced, and passes through raw (wrapping the unsigned host field) when PULP_HOST_QUIRKS=off. See docs/reference/host-quirks-policy.md.

Obj-C gotcha: in au_adapter.mm the @implementation method bodies are at file scope, NOT inside namespace pulp::format::au, so unqualified lookup of namespace free functions fails to compile. Qualify them: pulp::format::detect_host_info(), pulp::format::resolved_quirks(...), pulp::format::reported_latency_samples(...). (Struct members like the cached HostQuirks host_quirks resolve fine — the struct is in-namespace.) The core lib doesn't compile this .mm, so only the AU target/test catches such errors — build pulp-test-au-plugin-state.

synthesize_bypass_parameter

When the plugin declares no Bypass parameter and the quirk is enforced, the adapter calls pulp::format::maybe_synthesize_bypass(store, quirks) (in quirk_apply.hpp) right after define_parameters — injecting an automatable boolean "Bypass" param with the reserved ID kSynthesizedBypassParamId (0x70427970). The adapter's EXISTING bypass detection (name == "Bypass", boolean range) then adopts it, so the pass-through short-circuit honors it with no further wiring. PULP_HOST_QUIRKS=off synthesizes nothing. Existing "no-bypass" tests must set kQuirkFilterOff to keep that premise. (CLAP + AU v2 are NOT wired — they have no bypass process path; injecting a param there would appear-but-do-nothing, so they need separate design work.)

The StateStore must outlive the Processor

Processor::state() dereferences a pointer the host installs. A Processor may follow it for its whole lifetime — from process(), from its destructor, and from any worker thread that destructor is about to join(). So the host has to keep the store alive until the Processor is gone.

In practice that is one rule about member order: declare the state::StateStore before the std::unique_ptr<Processor>. Members are destroyed in reverse declaration order, so the store then dies last. Every host in core/format had it backwards until 2026-07; the effect is nothing at all for a Processor with no threads, and a use-after-free on plug-in close for one with a background thread that reads state().get_value() while the destructor walks to its join().

It crashes only on close, only sometimes, and the DAW gets the blame. The regression test is test/test_store_lifetime.cpp; it observes the store's destruction through a sentinel owned by a parameter's to_string closure rather than reading freed memory and hoping the result looks wrong.

A Processor should not rely on this either: a worker thread that reads the store on every tick is one host away from the same crash. Publish what the thread needs to atomics from process() instead.

UMP word lengths come from core/midi, and the MIDIEventList walk is testable

Do not hand-roll a words-per-message-type table in the adapter. core/midi's ump_words_for_message_type(uint8_t) is the single spec-complete source (every nibble 0x0-0xF, including the reserved ranges and the 128-bit UMP-Stream type 0xF), and UmpPacket::size_for_type delegates to it. A second, under-covering table is exactly the UMP-cursor-advance bug class: advancing an unrecognized message by 1 word re-reads its trailing words as fresh headers. The AU v3 MIDIEventList word-cursor walk is extracted out of the ObjC render block so it is unit-testable (truncated-packet + multi-word-advance vectors) rather than only reachable through a live AU host.

setFullState: runs under the state-restore gate

The property table at the top of au_adapter.mm marks setFullState: as main-thread with serdes. Hosts call it while the unit is rendering, and Processor::deserialize_plugin_state() is documented as running with the audio thread stopped, so the setter holds AUBridge::state_restore_gate across the deserialize and the render block takes the matching non-blocking render lock.

The render lock is acquired BEFORE the MPE sidecar phase, because bridge->mpe.run(*bridge->processor, …) already reaches into the Processor. On contention the block passes its input through, clears the sidechain pointer and resets triggers, then returns noErr — deliberately NOT via failClosed(), which would additionally set kAudioUnitRenderAction_OutputIsSilence and turn a brief restore into a labelled dropout.

AU has no note-name surface

Processor::note_names() lets a plug-in label individual keys — a drum kit's "Kick", a sampler's articulation switches — and CLAP and VST3 both publish it. AU has no host-side equivalent in either v2 or v3, so note_names() simply goes unread on this format.

That is a gap in the AU API, not an omission in the adapter. Do not invent a private property for it.

Tracing attaches for this format now (WAH-4)

Perfetto tracing used to be wired into VST3 only. A capture of a AU v3 session recorded nothing while Tracing's API described itself as process-global — so an empty .pftrace looked like an environment problem rather than a missing call.

This adapter now holds a runtime::ScopedTracingAttachment (PulpAudioUnit's _tracing ivar, reset explicitly at the end of -dealloc). Two things follow:

  • It is RAII, not a hand-balanced attach/detach pair. A leaked attachment is not benign: the .pftrace is only written by the FINAL detach, so one unbalanced instance means the capture silently produces nothing.
  • Declaration order is load-bearing. It must outlive every span this instance can emit, so it is declared to destroy LAST. The final detach also cancels and JOINS the auto-flush timer, which is what makes plug-in module unload safe — a detached timer thread that wakes after FreeLibrary / dlclose runs freed code.

No-op unless the build is configured PULP_TRACING=ON.

The bundle carries its own icon, and the plist key is what makes it work

pulp_app_icon(<target>_AU ...) brands the appex: it copies the .icns into Contents/Resources/ and sets MACOSX_BUNDLE_ICON_FILE, which CMake substitutes into the bundle's Info.plist at generate time.

The load-bearing half is easy to miss. That substitution needs a CFBundleIconFile key in tools/cmake/PulpInfoPlist.au.in to land in. Without it the copy still happens and the property is still set, so nothing errors — the bundle just comes out unbranded. If an icon does not appear, check the template for the key before suspecting the helper.

Prefer ICNS over SOURCE for a mark with fine detail. SOURCE derives every size from one PNG with sips, whose Lanczos kernel overshoots on hard edges: a feature one or two device pixels wide at 16x16 smears into its neighbours and the bundle edge picks up a bright halo. Render each size on its own pixel grid and pass the finished .icns.

The adapter asks for an editor prewarm when the host instantiates the plug-in

Right after the processor is created and its parameters defined, the adapter calls request_editor_prewarm(*processor) (PulpAudioUnit after the factory call, before the render resources exist). It hands Processor::editor_prewarm() (the scripts and materialized documents the editor evaluates on every open) to the background worker the view layer installs, once per plug-in bundle per process, never under a headless/CI environment or PULP_EDITOR_PREWARM=0. Keep the call after define_parameters() and outside any audio-thread path; a processor with no editor, or nothing to prewarm, costs one virtual call. The editor itself does not change: an open that races the worker waits for the in-flight result instead of compiling again. Details: view-bridge, "What the host shows while it waits".

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aax

無料

Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

android

無料

Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する