Skip to content
cccskills
FreePublished on GitHub

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.

The English summary is being prepared. Showing the original description.

See how to install

Files included (1)

  • SKILL.md48.5 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

AAX Skill

Use this when working on Pulp's optional AAX support or when guiding users who want to build and validate AAX plugins locally.

Scope

  • Supported hosts: macOS and Windows
  • Unsupported: Linux and Ubuntu
  • Current scope: AAX Native, including the custom editor (AAX_CEffectGUI) and parameter gestures
  • Out of scope: bundling Avid assets, DSP support, PACE release automation
  • Out of scope, with a caveat worth knowing: AudioSuite. The descriptor declares AAX_ePlugInRole_InsertOrAudioSuite, so the role is advertised, but Pulp registers no AAX_IHostProcessor — the dedicated offline-render path is not implemented. Say "the role is declared; the offline path is not implemented", not "AudioSuite works" and not "the role is absent".

The custom editor

Processor-only AAX is the default shipping safety lane

Plugins that need reliable host discovery and audio instantiation should use pulp_add_plugin(... AAX_HEADLESS FORMATS AAX ...) and emit PULP_AAX_PLUGIN. This target deliberately links pulp::format-core, omits aax_effect_gui.cpp and the view target, does not stage WebGPU/Skia sidecars, and advertises AAX_eProperty_UsesClientGUI = 0. It therefore cannot load the native editor stack while Pro Tools is scanning the component. The normal AU/VST3/CLAP/ Standalone target remains editor-enabled. This option is reusable by Spectr, Forge, GPU-NAM, and other products.

The AAX private-data lifecycle is also a hard gate: ResetFieldData must construct the processor state, reset must destroy and recreate it, and remove must destroy it exactly once. A nonzero private-storage pointer is not proof of construction; use the runtime lifecycle tests plus load_unload, parameters, and a host audio render before claiming DSP works.

Acceptance gates

For a macOS release build, run these in order:

  1. Build the headless AAX target with governed tooling and inspect otool -L; it must have no Metal, MetalKit, CoreHaptics, Skia, or WebGPU dependency and no libwgpu_native.dylib sidecar.
  2. Run the SDK-gated entry/lifecycle tests and the AAX Validator's test.describe_validation, test.load_unload, and test.parameters.
  3. Developer-ID sign nested non-PACE dylibs first, then PACE-wrap/sign the outer AAX. Run Apple strict verification and wraptool verify; never run outer codesign after PACE signing.
  4. Install exactly one copy under /Library/Application Support/Avid/Audio/Plug-Ins. A validator pass is not a Pro Tools audio proof: insert the plug-in in a real session and verify rendered or metered signal passes through it.

Keep AAX SDK, validator, PACE binaries, customer identifiers, passwords, and license receipts outside repositories and command transcripts. A private setup backup may record versions, paths, checksums, and setup steps, but never the credentials themselves.

core/format/src/aax_effect_gui.cpp embeds a Pulp editor in the Pro Tools plugin window through the shared, format-agnostic view::PluginViewHost — the same seam VST3 / AU v2 / AU v3 / CLAP use, so there is no AAX-specific render path to maintain. Its ViewBridge is built from ViewBridge::Options::hosted_editor() like every other hosted adapter — never a hand-assembled Options, and a structural test enforces that. See the view-bridge skill.

The editor is opt-in, and defaulting it off is deliberate — do not "fix" it. PULP_AAX_PLUGIN registers no editor, so a plugin gets Pro Tools' auto-generated parameter strip; PULP_AAX_PLUGIN_WITH_GUI registers the custom editor. The strip is plain but it works, and it is the only AAX UI any Pulp plugin has shipped with. The custom editor has not been validated in Pro Tools itself, so a plugin that merely rebuilds must not trade a working strip for an unproven editor — an author has to ask for it. pulp-test-aax-entry-registration asserts both directions; if you flip the default, that suite fails, and that is the point. Reach for PULP_AAX_PLUGIN_WITH_GUI in an example or template only once the editor has actually been driven in Pro Tools.

GUI acceptance gate (mandatory)

Never describe an AAX build as having a working editor from processor validation alone. Before handing an artifact to a user, record which mode was built:

Artifact modeEntry macroWhat the validator can proveRequired host claim
Processor-onlyPULP_AAX_PLUGINDescriptor, load/unload, parameters, and audio processing“AAX processor validated; editor intentionally absent”
Custom editorPULP_AAX_PLUGIN_WITH_GUIThe descriptor contains EffectGUI and the processor tests pass“Editor registration present”; rendering and interaction still require Pro Tools

The AAX Validator warning Effect doesn't contain EffectGUI. is a hard classification signal, not an ignorable warning. Treat it as a failed GUI gate and stop the handoff if the requested artifact is supposed to show the custom editor. A bundle named or compiled as *Headless* must likewise never be reported as GUI-capable.

There is no standalone AAX renderer that reproduces Pro Tools' view-container, window, GPU-surface, resize, and automation behavior. Unit tests can exercise create_effect_gui(), sizing, attach failure, and teardown; AAX Validator can prove registration and processor behavior; only Pro Tools proves that the editor actually appears and paints. The minimum GUI evidence is a current Pro Tools insert with the Spectr editor visible, plus a log or accessibility-tree record showing the editor container is populated. A blank FXTDMEditView counts as a failure even when the AAX shell and audio are working.

Build scripts and private handoffs must emit an explicit mode field (for example aax_ui_mode: headless or aax_ui_mode: custom-editor) beside the artifact hash. Do not infer the mode from the filename after signing; verify the entry macro/compile definition before signing and repeat the validator classification after signing. This prevents a processor-only emergency build from silently becoming the “latest GUI build.”

Gotchas that are specific to AAX and cost time if you rediscover them:

  • The proc pointer is the whole game. A custom UI appears only because get_effect_descriptions() registers kAAX_ProcPtrID_Create_EffectGUI alongside kAAX_ProcPtrID_Create_EffectParameters. Drop that one AddProcPtr and Pro Tools silently shows its auto-generated parameter strip no matter how good create_view() is. The validator's "does not contain EffectGUI" warning is the tell. That fallback is exactly why registering the proc conditionally is a safe default rather than a broken plugin.
  • The editor has its own Processor, deliberately. AAX splits the host-side data model from the real-time algorithm; the algorithm's Processor lives in its private data block and the model cannot reach it. So EffectParameters builds a second model-side Processor for create_view(). This is the exact opposite of AU v2, where a second Processor was a real bug (parameters drifted). The reason it is safe here: the AAX parameter manager — not either Processor — is the value authority, and the model mirrors it into the editor store both ways (UpdateParameterNormalizedValue in, AAX_IParameter::SetValueWithFloat out), via ParameterMirror in aax_editor.hpp.
  • The mirror stops the echo by comparing values, not with a re-entrancy flag. ParameterMirror writes back only when a write would actually change something. A transient "I am syncing" bool around the inbound write looks equivalent and is not: it encodes when the listener runs, and that is not guaranteed. StateStore runs a ListenerThread::Main listener inline only while no EventLoop is installed (set_main_loop() is never called on this store today) — install one and the flag is long cleared before the deferred listener fires. The rule is a statement about the two values, so it holds wherever the callback lands. It is load-bearing, not an optimization: StateStore::set_value() notifies unconditionally, even when nothing changed, so the echo has no fixed point of its own and would re-enter the host at its dispatch rate forever.
  • Never compare the store's value against the parameter's raw real value. They are not in the same coordinate system, and == between them does not terminate. Two lossy transforms sit on the two sides: AAX's value has been through the taper (NormalizedToReal of whatever the host sent, snapping to ParamRange::step only when a step is set), while the store's has been through constrain_stored_value, which applies an implicit step of 1 to any non-Continuous ParamKind even when the range declares no step. For a discrete parameter with no explicit step the two are therefore structurally different values, == is never true, and — because SetValueWithFloat does not store what it is handed — the corrective write never reproduces them either. That is an unbounded automation write stream reaching Pro Tools. ParameterMirror instead asks two questions, both required: do the two sides normalize to the same value (taper_real_to_normalized — the normalized token is the only thing that actually travels to the host), and would the write land AAX where it already is (aax_value_after_write, for ranges whose real and normalized grids do not line up — a discrete kind over a wide skewed range is the case that bites). A per-parameter fuse (kCorrectionFuseLimit) bounds any remaining host quirk and logs it rather than wedging the host. Covered by pulp-test-aax-editor (SDK-free), whose sweep drives 21 ranges × 4 kinds × 201 start points through the real taper and requires every case to settle in one write with the fuse never firing.
  • AAX_IParameter::SetValueWithFloat does not store the value. It posts a request through the automation delegate (AAX_CParameter::SetValue does Touch(); PostSetValueRequest(Identifier(), RealToNormalized(newValue)); Release();), so GetValueAsFloat keeps returning the old value until the host answers with UpdateParameterNormalizedValue — the same path a fader or control surface takes. Code that assumes a synchronous write will misread the parameter. What comes back is a normalized value, stored as NormalizedToReal of it, so AAX ends up holding denormalize(normalize(constrained)) and not the value that was written.
  • AAX_IParameter::GetValueAsFloat can fail without writing its out-param. It answers false and leaves the float untouched for any parameter whose value type is not float (SetValueWithFloat refuses the same way). Pulp registers only AAX_CParameter<float> today, so it always succeeds — but ignoring the return means the day that changes, a caller reports success with a fabricated zero. Propagate the bool.
  • A test double for the parameter manager must round-trip the taper. The manager's asynchrony is the easy half to model; the value transform is the half that hides bugs. A fake that echoes the real value back verbatim, or that pushes it with set_value where the adapter uses set_normalized, drops both lossy transforms and can only ever confirm whatever rule the mirror already implements — it cannot fail on a mirror-convergence bug by construction.
  • Update values only through AAX_IParameter::SetValue*. Avid's own header is explicit that a GUI must never call UpdateParameterNormalizedValue directly; SetValue* manages the automation locks and posts coefficients.
  • Gestures are not optional. Without TouchParameter / ReleaseParameter a custom UI records every edit as an isolated automation point instead of a stroke — worse than shipping no UI. state::Binding gestures route through GestureRouter (aax_editor.hpp), which enforces AAX's balance invariant.
  • AAX_Point's constructor is (vert, horz) — vertical first. Passing width first silently transposes the editor.
  • Sizing is plugin-driven. AAX reads a size from GetViewSize(); the plugin pushes later changes through AAX_IViewContainer::SetViewSize. Follow the AU v2 model (forward native size changes), not VST3's.
  • Pin the viewport at the AUTHORED box, through the shared contract. The editor shell calls configure_native_viewport() (plugin_descriptor.hpp) like VST3 and AU v2 do, so an imported 1320x860 design that opens in a 990x645 host frame lays out at 1320x860 and paint scales it 0.75x. plan_editor_size carries the same box in design_width/height. Pinning the preferred size instead paints the authored tree at scale 1 into the smaller frame: the host shows the top-left corner of the design, clipped right and bottom, and only a grip drag (which goes through commit_editor_requested_viewport) repairs it. That is exactly the Spectr first-open failure in Pro Tools.
  • Place the native child at the parent's top-left and reconcile the parent. PluginViewHost::attach_to_parent only adds the child at origin (0,0), which is AppKit's BOTTOM-left; a host parent larger than the child shows the editor in its lower-left corner. aax_native_view.hpp (aax_place_child_top_left, aax_fill_parent, aax_native_geometry) keeps that placement AAX-only — no other format's attach path changes, and pulp-test-aax-native-view proves a same-size parent is a no-op. After the first frame the shell reads the parent, re-asserts the preferred size once through SetViewSize if the parent disagrees (JUCE's parentSizeChanged does the same), and if the host keeps an off-size parent the child fills it so the pinned viewport letterboxes a whole, proportional editor rather than a clipped corner.
  • The editor runs on the Processor that renders audio. The data model constructs the algorithm instance in ResetFieldData and binds its editor to that instance's Processor and store (bind_algorithm). A second, model-side Processor — the previous design — never processes a block, so everything that is not a host parameter was dead in Pro Tools: published analyzer/meter frames, LFO/modulation animation, transport-driven state, set_gpu_processing (which only acts on a prepared Processor), and any editor toggle stored on the Processor rather than in the store. Band gains and freeze still worked because they are parameters and travel through the coefficient packet. Consequences to keep straight: sync_from_packet writes with set_value_rt so the editor's parameter mirror (a Main-thread listener drained by the ViewBridge pump) never runs under the render-thread no-alloc guard; ResetInstance re-prepares in place instead of recreating the Processor an open editor points at; RemovingInstance fires EditorHost::set_editor_invalidation_handler so the GUI tears down before the delete; model and instance may die in either order and unhook under binding_mutex(). Lifecycle tests: test_aax_entry_registration.cpp [view-bridge], driven through aax_runtime_testing.hpp.
  • Read the host receipt from Pro Tools' stderr, not the unified log. runtime::log_info writes to os_log and stderr, but log show returned zero dev.pulp.runtime lines over three days on a dev Mac, so it is not an instrument here. Launch the host binary directly with stderr redirected ("/Applications/Pro Tools.app/Contents/MacOS/Pro Tools" 2>receipt.txt &) instead of open -a. The shell logs every GetViewSize / GetMinimumViewSize answer (first eight, tagged "no editor yet" vs "editor open"), every SetViewSize result, the parent/child/window frames at attach, after reconciliation, and again at +1s and +3s. A wrong outer frame is then attributable: the plug-in's answer, or the host's own window.
  • Never defer to AAX_CEffectGUI::GetViewSize() / GetMinimumViewSize(). Both base implementations return AAX_SUCCESS without writing the point, so the host reads back whatever it passed in and the plug-in appears to have agreed to it. There is no "let the base class answer" fallback here. AAX may ask for a size before any window exists, but the plug-in's hints are always reachable: GetEffectParameters() yields the data model, and EditorHost::editor_view_size() returns Processor::view_size(), which needs no view tree. Answer from that, and report AAX_ERROR_NULL_OBJECT when the plug-in genuinely has no editor.
  • The editor model's member declaration order is load-bearing. EditorModel in aax_runtime.cpp declares store before processor because members die in reverse declaration order and Processor::state() dereferences a pointer to that store — a Processor may touch it from its destructor or from a worker thread that destructor joins. The router is declared before the Processor too: a parameter write from ~Processor routes through the store's gesture callbacks. static_assert(offsetof(...)) guards both, so a reorder fails the build rather than the teardown. Every other adapter (vst3_adapter.hpp, standalone.hpp, au_v2_instrument.hpp, headless.hpp) carries the same store-before-Processor convention.
  • The editor needs Skia. Without PULP_HAS_SKIA the Windows PluginViewHost falls back to the no-op stub factory, create() returns null, and the plugin loads with no editor.

Pro Tools acceptance learnings (Spectr, 2026-10)

Hard-won on the first real AAX editor; each one cost hours and none is visible from validators or screenshots alone.

  • Pro Tools scans the plug-in folder recursively. A mis-staged package once installed a second bundle under Plug-Ins/Library/Application Support/…, and Pro Tools kept loading that stale copy while every later install replaced the top-level one — a full day of "no source change has any effect". Before judging a host run, find the plug-in tree with no depth limit, and prove the loaded binary with a string that exists only in the new build (strings … | grep -c, positive AND negative control).
  • The outer frame can lie about who chose it. A screenshot cannot say whether a wrong window came from the plug-in's answer or from the host; only the stderr receipt (GetViewSize/SetViewSize with results, native parent/child/window frames at attach and later) can. Pro Tools hands over a 0x0 parent and sizes it from the plug-in's transaction, synchronously.
  • A flexible autoresizing mask against that 0x0 parent doubles the child. AppKit applies the parent's size delta: a 990x645 child becomes 1980x1290 when the parent reaches 990x645, and the resize callback then publishes the doubled frame. Freeze tracking around the transaction, place the child top-left, and never echo a frame the host already has.
  • GPU audio is a build option, not a runtime discovery. Spectr's Mixing renderer switches to the GPU only when the product was configured with SPECTR_EXPERIMENTAL_SHARED_RENDERER=ON (which links Pulp::gpu-audio); an AAX built without it shows the toggle but gpu_processing_available() is false. Mirror the flags of the AU/VST3 build that "works".
  • Automation is "automatable" at the plug-in and "automation-enabled" at the host, and only the host can flip the second. Pro Tools records nothing for a parameter until the user enables it (the plug-in window's auto button → Plug-in Automation dialog) or sets Preferences → Mixing → Automation → "Plug-in Controls Default to Auto-Enabled". The SDK has no property for it; the automation delegate's header says so explicitly. A receipt full of accepted touches and echoed values with no lane written is this, not a plug-in bug.
  • Trace a hosted plug-in through the environment, not the broker. A PULP_TRACING=ON build records when PULP_TRACE_PATH (a directory ending in /), PULP_TRACE_SECONDS and PULP_TRACE_RING_KB are set in the HOST's environment — launch the Pro Tools binary directly with them — or read from ~/.config/pulp/trace-autostart for hosts that inherit nothing. Each process writes <process>-<pid>.pftrace. pulp control instances lists only the standalone control host, so the broker path does not reach a DAW-hosted instance.
  • AAX_EUpdateSource cannot tell automation playback from a GUI edit. Both reach UpdateParameterNormalizedValue as Unspecified; Parameter marks a re-entrant write and Chunk a preset load. Prove playback by value updates arriving with no TouchParameter/ReleaseParameter around them (the adapter logs both).
  • Two indicators on a modulated knob are the design, not a bug. The violet needle and arc sit at the value playing; the short white tick is the base the user set (or host automation plays). Check the product's modulation doc before "fixing" a renderer difference that is not one.
  • The rounded window corner on macOS 26+ masks a flush resize grip. Keep the grip's hit box flush but inset its ink by ~6pt. Where the grip lives today: Pro Tools draws no resize affordance of its own (like AU v2), so the product opts in per format — Spectr's aax_entry.cpp calls set_editor_owns_resize_grip(true) and its EditorResizeGrip (a pulp::view::ResizableCorner subclass in src/ui/native_editor.cpp) resolves the drag through Processor::request_editor_resize, which the AAX shell turns into SetViewSize. SDK follow-up: move that grip into Pulp so every AAX/AU v2 editor gets it without product code — the shell already knows the format has no host grip.

macOS host acceptance and poka-yoke

The SDK-free tests and AAX validator do not prove that Pro Tools can create the custom editor or process a signal. A macOS acceptance run is closed only after all of these are recorded against the exact installed bundle:

  • Build the AAX target from the exact Pulp runtime/SDK input and record its source provenance. Do not resolve an older sibling SDK from CMake's search path.
  • Before installing, run the AAX validator's load/describe/parameter checks, Apple deep-strict verification, and PACE verification. The validator's EffectGUI warning means the strip lane was built; it is not custom-editor proof.
  • Install one bundle only. Quit Pro Tools before replacing the bundle, install through the machine's approved package wrapper, then verify the installed executable hash equals the signed artifact. Clear/reload Pro Tools' plugin cache when discovery disagrees with that hash.
  • The first editor transaction must be checked in Pro Tools: the preferred width/height, aspect lock, and resize grip must be visible before any manual drag. AAX_Point is (vertical, horizontal). The adapter seeds the host and bridge geometry at the preferred size and then reconciles the native child against the parent Pro Tools built, because the host may not emit an initial native frame callback. Capture the geometry receipt from stderr (above) in the same run; a screenshot alone cannot say which side chose the frame.
  • A real host run must separately prove mono and stereo discovery, editor creation, proportional resize, audio-through, active-versus-bypass output, bounce output, transport tempo, LFO animation, and automation write/read playback. A DigiShell load or parameter enumeration is only a host-load signal; it is not audio-through or bounce evidence.

Keep Avid/PACE SDKs, customer identifiers, signing credentials, and signed artifacts outside the public repository. A private machine capsule may store restore scripts and Keychain-backed lookup instructions, but never passwords or customer numbers in argv, logs, chat, or tracked source.

The SDK-free logic (gesture routing, sizing) lives in core/format/include/pulp/format/aax_editor.hpp on purpose, so it is testable with no Avid SDK: pulp-test-aax-editor runs everywhere.

"SDK-gated" currently means "never built," not "built elsewhere." No Avid SDK is present in Pulp's CI or on any Pulp development machine, so pulp-test-aax-effect-gui, pulp-test-aax-midi-node, and pulp-test-aax-entry-registration are configured out of every lane that runs. Do not cite them as coverage for a claim: the AAX behavior actually verified on every push is the SDK-free part. If you have an Avid SDK locally, building these suites is the only way they have ever run — and worth doing before you assert anything about the SDK glue.

  • Never commit the AAX SDK, DigiShell, validator binaries, or Avid example code.
  • Never unpack Avid downloads inside the Pulp repo.
  • Keep AAX developer-supplied, opt-in, and out-of-tree.
  • Run the repo audits after AAX-related changes:
python3 tools/deps/audit.py --strict

What Users Should Download

Tell users to sign in at:

https://developer.avid.com/aax/

Required downloads:

  • AAX SDK
  • DigiShell and AAX Validator

Optional later:

  • AAX Plug-In Page Table Editor

Do not recommend these for normal Pulp AAX setup unless the task explicitly needs them:

  • AAX Developer Tools beta bundles
  • Pro Tools installers
  • HD Driver
  • Avid Cloud Client Services

Suggested Install Locations

Preferred user-local locations so Pulp can auto-discover them:

~/SDKs/avid/aax-sdk/current
~/SDKs/avid/aax-validator/current
%USERPROFILE%\SDKs\avid\aax-sdk\current
%USERPROFILE%\SDKs\avid\aax-validator\current

current/ must be the SDK/validator root, not contain a nested wrapper. The Avid archives unzip to a versioned dir (e.g. aax-sdk-2-9-0/, aax-validator-dsh-2024-6-0-…-mac-arm64/), so a common mistake is leaving current/aax-sdk-2-9-0/Interfaces/.... Move the contents up (or symlink current → the versioned dir) so current/Interfaces/AAX.h and current/CommandLineTools resolve directly. pulp doctor confirms discovery. The user-facing worked example lives in docs/guides/aax.md.

Environment variables override auto-discovery:

export PULP_AAX_SDK_DIR=~/SDKs/avid/aax-sdk/current
export PULP_AAX_VALIDATOR_DIR=~/SDKs/avid/aax-validator/current

macOS PACE and private tool backup

The Avid SDK and DigiShell validator are developer-supplied materials. Keep them outside every public repository, preferably at:

~/SDKs/avid/aax-sdk/current
~/SDKs/avid/aax-validator/current

Keep PACE Fusion installers and a small hash manifest in a private directory, for example:

~/SDKs/private/pace/fusion/6.0.1/

The manifest should record the source files, Fusion version, date, and SHA-256 hashes. Do not put iLok credentials, developer certificates, wrap configs, or license receipts in Git, shell history, command arguments, or build logs.

The macOS Fusion package installs several components. The Fusion_tools_Lite component provides wraptool, normally at:

/Applications/PACEAntiPiracy/Eden/Fusion/Versions/6/bin/wraptool

The full installer has a brittle preflight that rejects any process whose name contains xcodebuild. Confirm that no real xcodebuild compilation is active before installing. Persistent xcodebuildmcp helpers can trigger the same message even when no build is running; do not kill shared helpers merely to satisfy that check. If needed, install the signed component packages from the mounted PACE installer individually, preserving the parent DMG/pkg hash and recording the component versions. Existing iLok/license support may already be installed; verify receipts with pkgutil --pkg-info before upgrading.

After installation, verify the expected macOS receipts and tool path without printing account data:

pkgutil --pkgs | grep -E 'com\.paceap\.pkg\.eden\.(licensed|activationexperience|fusion\.tools\.lite\.6|iLokLicenseManager)'
ls -l /Applications/PACEAntiPiracy/Eden/Fusion/Versions/6/bin/wraptool
/Applications/PACEAntiPiracy/Eden/Fusion/Versions/6/bin/wraptool list

Keep the original installer DMG/PKG and a SHA-256 manifest in the private backup. A useful restore record includes the Fusion version, package names, installation date, and hashes; it does not include iLok credentials, customer numbers, certificates, or wrap passwords.

PACE installation does not sign an AAX binary automatically. Treat these as separate gates:

  1. AAX compile/link against the out-of-tree Avid SDK.
  2. Apple/PACE signing with the developer's own identities and wrap configuration.
  3. DigiShell/AAX Validator checks.
  4. Discovery and editor/audio smoke in Pro Tools.

An ad-hoc or linker-signed bundle can pass data-model and parameter validation while still failing wraptool verify or Pro Tools's production signature requirements. Never claim the signing or Pro Tools gates from a successful compile alone. Windows setup and signing remain a separate, deferred path.

Poka-yoke signing and release gates

Make the safe path the easy path and make an unsafe path fail before it can produce a misleading artifact. These checks are deliberately redundant:

  1. Keep trust domains separate. A connected, certified iLok and a successful wraptool list prove that the local PACE license can be used. They do not prove that a bundle was signed. Require a fresh wraptool verify on the exact final bundle before calling it signed.
  2. Keep secrets out of process arguments. Never put an iLok password, customer number, wrap password, certificate private key, or receipt JSON in chat, shell history, an environment capture, a repository, or an argv string. Prefer the interactive PACE prompt or a permissions-restricted file-backed configuration owned by the developer. If a secret is ever pasted into a transcript, rotate it before continuing.
  3. Refuse unsigned-looking inputs. Sign only a freshly built bundle from a known build directory. Record its SHA-256 before signing and record the post-signing SHA-256 separately; never overwrite the only unsigned copy.
  4. Seal the bundle shape before Apple signing. Arbitrary JSON evidence files under Contents/MacOS are treated as nested code by codesign. The build writes Pulp's *.inspector-capabilities.json, *.control-shipping.json, and *.control-shipping-report.json sidecars to Contents/Resources; still fail if any of those files remain under Contents/MacOS (a bundle from an older SDK, or a stale copy).
  5. Use ordered gates. The only valid order is: build, inspect bundle shape, Apple/PACE sign, wraptool verify, codesign --verify --deep --strict, AAX Validator, install the exact verified bundle, then prove discovery and load in Pro Tools. A passing validator or a certified iLok cannot substitute for a later gate.
  6. Use negative controls when changing the workflow. A deliberate unsigned copy must fail wraptool verify; a deliberate bundle with a sidecar left in Contents/MacOS must fail the Apple verification step. Keep these checks disposable and outside public repositories.
  7. Make installation identity-based. Install only from the verified artifact, copy to the system and user AAX plug-in locations only when needed for the host test, and compare the installed bundle hash to the verified source hash. Remove stale copies before retrying discovery so a host cannot load an older build by accident.
  8. Report evidence, not intent. A release handoff must include the exact source commit, artifact path, artifact SHA-256, signing and validator receipts, install locations, and host result. If Pro Tools was not launched, say that the host gate is pending.
  9. Drive DigiShell through DTT. CommandLineTools/dsh is an interactive shell, not a conventional --help command. Do not invoke it with empty stdin or a pipe that can close early; that can leave a high-CPU process emitting unbounded Got empty command failures. Use the supplied DTT/run_test.command --script ... entry point, put logs in a disposable private directory, and bound the run with an external timeout. A failed or timed-out harness is an inconclusive validator result and must be reported as such.
  10. Interpret validator coverage honestly. test.describe_validation, test.data_model, test.load_unload, test.parameter_traversal.random.fast, and test.parameters are useful core gates. test.page_table.automation_list may pass with a message that no page tables are registered; that is valid when the plug-in declares none. test.page_table.load is only applicable when a page-table library is shipped, so a failure saying that the library is absent is an expected non-applicability result, not evidence that the plug-in's data model failed. Keep it separate from the required pass set.

For a local macOS run, the minimum preflight should be equivalent to:

set -euo pipefail
test -x /Applications/PACEAntiPiracy/Eden/Fusion/Versions/6/bin/wraptool
test -d "$AAX_BUNDLE"
# `set -e` ignores a `!` pipeline, so a negated find would never stop the
# script. Test the match explicitly.
if find "$AAX_BUNDLE/Contents/MacOS" -maxdepth 1 -type f \
    \( -name '*.json' -o -name '*.inspector-capabilities.json' \
       -o -name '*.control-shipping.json' \
       -o -name '*.control-shipping-report.json' \) -print -quit | grep -q .; then
    echo "sidecar left in Contents/MacOS" >&2
    exit 1
fi
# The bundle names its own executable; read it rather than requiring it.
AAX_BINARY="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
    "$AAX_BUNDLE/Contents/Info.plist")"
shasum -a 256 "$AAX_BUNDLE/Contents/MacOS/$AAX_BINARY"

The command is a shape check, not a signing recipe: the actual wrap configuration and credentials stay in the developer's private PACE setup. When the PACE account reports license verified, record that as the local authorization precondition and continue through the independent signing and host gates rather than treating it as completion.

On macOS, wraptool sign also needs either a PACE-issued customer number or a local wrap-configuration file in addition to the Apple Developer ID signing identity. If neither is available, stop at the compile/validator gates and report that missing PACE configuration; do not guess identifiers or put a password in a command line.

Optional macOS environment management with mise

mise can be useful for the non-proprietary parts of this setup: pinning supported CLI versions, defining repeatable build/validate tasks, and selecting environment variables such as PULP_AAX_SDK_DIR and PULP_AAX_VALIDATOR_DIR. Keep that configuration in a private machine-setup repository if it contains local paths. Do not use mise to distribute or manage the Avid SDK, DigiShell, PACE Fusion binaries, iLok state, signing certificates, customer numbers, wrap configurations, or credentials. Those remain developer-supplied macOS assets and should be restored from the private backup with their recorded hashes. Windows setup and signing can adopt the same boundary later, but is intentionally not specified here.

Keep a private restore capsule for onboarding another Mac, outside the Pulp checkout and never published. It should contain:

  • a README.md restore runbook;
  • a restore manifest that records the required archive hashes and lists the machine-local state deliberately left out;
  • a restore script that verifies each archive hash and its expected paths before extracting anything;
  • optionally, a mise.toml holding only the readiness and bundle-verification checks, never assets or credentials.

Move the capsule through your private backup channel. A new Mac still needs its own iLok activation and sign-in, PACE authentication, Apple Developer ID private key, and Pro Tools installation; none of those travel in the capsule.

Core Commands

Check current AAX availability:

pulp status
pulp doctor

Build with AAX enabled:

cmake -S . -B build \
  -DCMAKE_BUILD_TYPE=Debug \
  -DPULP_ENABLE_AAX=ON \
  -DPULP_AAX_SDK_DIR="$PULP_AAX_SDK_DIR"
tools/ci/governed-build.sh cmake --build build --target MyPlugin_AAX

Validate built plugins:

pulp validate
pulp validate --all

Notes:

  • pulp validate uses the faster AAX describe-validation path when the validator is installed.
  • pulp validate --all runs the broader AAX validator suite.
  • Do not launch multiple full AAX validator runs in parallel; DigiShell can collide on local ports.

Expected UX

  • If the AAX SDK is missing, point users to the Avid sign-in page and PULP_AAX_SDK_DIR.
  • If DigiShell/AAX Validator is missing, point users to the Avid sign-in page and PULP_AAX_VALIDATOR_DIR.
  • On Linux or Ubuntu, explain that AAX is unsupported and remove AAX from FORMATS.
  • If validation reports that a bundle exists but the plugin binary is missing, build the target before validating it.

Gotchas

A run-time latency change reaches Pro Tools through TimerWakeup

EffectInit reports definition_.latency_samples once. A processor that changes its latency later (a renderer switch, a lookahead toggle) raises flag_latency_changed() the way every other format expects; the AAX model consumes it in TimerWakeup() (pending_latency_report(), off the audio thread, under the binding mutex) and calls SetSignalLatency again, logging AAX host: latency changed, reported N samples. Before this the AAX adapter never consumed the flag, so Pro Tools kept compensating for the load-time latency after a Mixing GPU/CPU switch while AU, VST3 and CLAP re-aligned. Test: "AAX model republishes a latency the processor changed at run time" (pending_latency_report_for_test).

Every declared layout is its own component; render through the instance's

supported_bus_layouts becomes one AAX component per layout (its own ProcessProc, stem formats and native ID). The host picks one when it instantiates, and the only way the plug-in learns which is Controller()->GetInputStemFormat() / GetOutputStemFormat(). The render callback used to take definition.components.front() for its channel counts, so a mono-track instance of a plug-in that declares Stereo first built TWO input pointers from a context that carries ONE: Pro Tools logged a storm of CoreAudio -9173 deadline misses, then Found stuck plugin and DAE -9004 five seconds after InstantiatePlugIn (no crash report; it is a stall). With Mono first the same bug made a stereo instance process one channel, which is easy to miss by ear.

ResetFieldData now resolves InstanceState::component_index from the controller's stems (select_component_layout, exact pair first, then output stem, then 0) and the callback reads state.layout(). It also prepares there, on the control thread, with Controller()->GetSampleRate() and the kAaxPreparedMaxBlock ceiling, so the first block never runs a heavy Processor::prepare on the audio thread. A bare model with no controller (a unit test) keeps component 0 and the lazy prepare.

Tests: "AAX layout selection follows the host's stem formats" and "AAX mono instance renders through the mono layout even when stereo is declared first" (render_for_test drives process_callback with host-shaped pointer arrays whose second slot is null). Field checks: the Pro Tools cache Spectr.aaxplugin_pluginrunner.plugincache.txt lists numPlugIns: N (one per stem format registered) and strings <bundle>/Contents/MacOS/<name> | grep -c '^Stereo$' proves a layout name made it into the binary. The AAX Validator's only processing test, test.cycle_counts, reports E_LOST on this host, so an AAX render path is proven by the unit seam or by Pro Tools, never by dsh.

MIDI sysex accumulator

AAX splits multi-byte sysex (F0 … F7) across sequential AAX_CMidiPacket entries — the first packet carries the F0 status byte, continuation packets can appear with no status byte, and the final packet carries F7. Each packet's mData field is at most 4 bytes. A single-packet decoder will silently drop everything after the first 4 sysex bytes, and the host will never see the real message.

The correct shape is a per-node accumulator:

std::vector<uint8_t> sysex_buffer;
bool sysex_in_progress = false;
int32_t sysex_start_offset = 0;
// for each packet in the node's buffer:
//   if byte == 0xF0 → start accumulator, capture start_offset from mTimestamp
//   while sysex_in_progress → append every payload byte (no status-byte requirement)
//   if byte == 0xF7 → flush accumulator as one MidiEvent, reset

This matches the shape used for CLAP/VST3/AU/CoreMIDI/ALSA sysex. The state machine itself is SDK-free and unit-tested in core/format/include/pulp/format/aax_midi_packets.hpp (decode_midi_packets() for input, fragment_sysex() for output) — because aax_runtime.cpp is gated behind the developer-supplied SDK and is not compiled in stock CI, the only way to test the reassembly/fragmentation is to keep it out of the AAX_*-typed translation unit. decode_midi_node / encode_midi_node now live in core/format/src/aax_midi_node.cpp (declared in core/format/include/pulp/format/aax_midi_node.hpp, called from aax_runtime.cpp); they just translate AAX_CMidiPacket <-> MidiPacketBytes and delegate, so the tested code is the shipping code. When you change either path, change aax_midi_packets.hpp (and its tests in test/test_aax_midi.cpp, which run in default CI), not a copy inside the runtime.

The thin SDK glue itself has a test but no lane that builds it: test/test_aax_midi_node.cpp is SDK-gated (built only when PULP_HAS_AAX) and drives decode_midi_node / encode_midi_node through real AAX_IMIDINode / AAX_CMidiStream / AAX_CMidiPacket fakes. Run it on an AAX-SDK machine (ctest -R aax-midi-node after an -DPULP_ENABLE_AAX=ON build) to verify the delegation; stock CI still cannot compile it. The AAX bypass MIDI-thru helper must copy sidecar payloads with MidiBuffer::add_sysex_copy(); MidiBuffer::SysexPayload is deliberately not a movable raw std::vector.

When clearing an AAX process block's MIDI buffers, clear both the short-event storage and the sysex sidecars. MidiBuffer::clear() resets short events only; call clear_sysex() on both input and output buffers before decoding the next block, or stale sidecar payloads can be re-emitted by a later block.

When adding or changing any AAX MIDI input path, exercise this against a multi-packet sysex vector (at least one packet across the 4-byte boundary and one terminator-only packet) in a unit test. Adapter fixes should ship with the regression tests that prove the fixed behavior.

Bypass audio must be latency-compensated (PDC alignment)

The AAX bypass short-circuit must NOT memcpy the dry input straight to the output. When the wrapped Processor reports a non-zero latency, the host has already delay-aligned the plugin's wet path by that latency (PDC), so a raw dry copy arrives latency samples early — comb-filtering on parallel/mix busses. Route the bypass pass-through through boundary::render_bypass_passthrough (in adapter_boundary.hpp), sizing the shared LatencyCompensatedBypass delay line to aax_reported_latency(definition.latency_samples) in ensure_prepared() — the same value SetSignalLatency() reports to the host. A zero latency collapses to a straight passthrough. VST3/CLAP/AU v2/v3 use the identical helper; a shared bit-exact fixture in test_adapter_boundary_parity.cpp ([bypass]) covers it since the real AAX runtime can't build without the Avid SDK.

Bind a parameter store before asking the processor anything

build_plugin_definition() (aax_model.cpp) describes the plug-in from a fresh factory() instance during registration — before Pro Tools or the AAX validator ever prepares it. A plug-in whose latency_samples() (or descriptor()) reads a parameter calls state(), which dereferences the processor's store pointer; with no store bound that is a SIGSEGV at registration, and the host reports the plug-in as failing its describe step with no Pulp log. Every other adapter binds a store and calls define_parameters() before these reads, so a plug-in that works in VST3/AU/CLAP crashed only in AAX.

The definition builder now binds a scratch StateStore (declared before the processor, so it outlives it) and runs define_parameters() immediately after factory(), then reads descriptor() and latency_samples(). The registered latency is therefore the latency at default parameter values. Any new adapter-side code that instantiates a throwaway processor to read metadata must do the same; [aax][model][latency] in test_aax_model.cpp covers it SDK-free with a processor whose latency is a parameter default.

The editor opens on the plug-in's own background

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"). After attaching and notify_attached() the GUI calls ViewBridge::prepare_first_frame(*host_), so the document is mounted and presented before Pro Tools composites the editor (view-bridge, "Editor open"); this path is unverified in Pro Tools itself.

The editor's GPU surface is a SUBSCRIPTION, not a one-shot read

aax_effect_gui.cpp must not sample host_->gpu_surface() once and hand the result to the scripted UI session. That read is only valid on hosts which build their surface in the constructor; the Windows host — the one Pro Tools loads — creates its Dawn surface inside try_attach_to_parent(), so a read taken before that call returns nullptr forever and the editor's WebGPU JS silently renders through mocks.

Use the shared helper, BEFORE the attach:

gpu_surface_binding_ = bind_gpu_surface(*host_, bridge_->scripted_ui(),
                                        gpu, "AAX");
if (!host_->try_attach_to_parent(parent)) { gpu_surface_binding_.reset(); ... }

Reset the subscription in teardown() before bridge_->close() — the observer writes into the session that call destroys. Full contract: the view-bridge skill's "GpuSurface plumbing into WidgetBridge".

Offline render is visible only for AudioSuite instances

AAX gives an Insert no offline-bounce signal, so a Pro Tools bounce of an insert still reports ProcessMode::Realtime. An AudioSuite instance does render offline: GenerateCoefficients() asks AAX_IController::GetIsAudioSuite() and writes the answer into the last parameter-packet slot (render_mode_packet_slot(), after bypass at 0 and the parameters at 1..N), and the algorithm decodes it with process_mode_from_packet(). The packet is therefore parameters + 2 floats; a change to the packet layout must keep the render-mode slot last and update test_aax_model.cpp.

Review Checklist

Parameter semantics and declared layouts

AAX bindings consume the shared ParamInfo::kind, value_labels, and canonical parameter text helpers. Do not infer discreteness from range.step or compute AAX step counts as intervals: param_value_count() is the number of host values. PluginDescriptor::supported_bus_layouts expands to one AAX component per declared configuration, with a distinct derived native ID; AAX stem-incompatible configurations are rejected by the model instead of silently advertised. The SDK-free AAX model tests are required coverage for both.

After any AAX-related change:

  1. Build with AAX disabled and confirm the repo still works normally.
  2. Build with PULP_ENABLE_AAX=ON against a developer-supplied SDK.
  3. Run pulp validate and pulp validate --all when the validator is installed.
  4. Recheck the user-facing guidance in docs/guides/aax.md if behavior changed.

Tracing attaches for this format now (WAH-4)

Perfetto tracing used to be wired into VST3 only. A capture of a Pro Tools/AAX 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 (InstanceState::tracing). 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.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

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.

The English summary is being prepared. Showing the original description.

Generous-Corp/pulp22Updated Oct 19, 2026

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.

The English summary is being prepared. Showing the original description.

Generous-Corp/pulp22Updated Oct 19, 2026

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.

The English summary is being prepared. Showing the original description.

Generous-Corp/pulp22Updated Oct 19, 2026

ara

Free

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

The English summary is being prepared. Showing the original description.

Generous-Corp/pulp22Updated Oct 19, 2026

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.

The English summary is being prepared. Showing the original description.

Generous-Corp/pulp22Updated Oct 19, 2026

Reproduce and debug "only happens in a DAW" audio plugin bugs (cutouts, glitches, parameter-change failures) entirely offline — headless Processor scenes for DSP bugs and a standalone AudioUnit host probe for adapter/host-interaction bugs. Use when a plugin misbehaves in Logic/Live/etc. but unit tests are green.

The English summary is being prepared. Showing the original description.

Generous-Corp/pulp22Updated Oct 19, 2026

See all skills by Generous-Corp

Report a problem with this skill