Skip to content

Keep parameters in sync while the host is not rendering (AUv3 #538, AUv2 #551) - #556

Merged
defiantnerd merged 4 commits into
nextfrom
fix-passive-parameter-sync
Sep 14, 2026
Merged

defiantnerd merged 4 commits into
nextfrom
fix-passive-parameter-sync

Conversation

@defiantnerd

Copy link
Copy Markdown
Collaborator

Closes #538. Closes #551.

The same bug in two formats: parameter traffic is gated on a processing path that is not running, when CLAP provides flush() precisely so that it need not be.

AUv3 (#538)

Parameter changes stopped moving in both directions whenever a host stopped rendering without deallocating — Audio Hijack with its block off, REAPER on an idle track — and resumed only when processing did.

  • Outbound: param_request_flush() only logged, so a value the plugin's editor changed sat in its queue indefinitely. It now records the request and the main-queue idle timer services it.
  • Inbound: the observer and bypass setter decided delivery from renderResourcesAllocated. Apple documents that as resource-allocation state, and a host may hold resources for a unit it is not rendering — so it cannot answer whether a render will come. It now selects only the path (adapter queue vs. direct flush), and the queue is drained by the idle tick when no render drains it first.

What decides is the absence of renders, not a flag: the render block records that it ran, and the idle timer flushes once three block periods have passed without one, sized from the sample rate and maximumFramesToRender. A rendering host never reaches the flush path.

Adds Clap::AUv3::ProcessAdapter::flush(), which drains the host queue into input events, calls the plugin, and forwards parameter and gesture output events to the automation sink. Note and MIDI output are dropped there deliberately — the AU MIDI output blocks may only be called from inside a render.

AUv2 (#551)

SetParameter queued onto the process adapter, which does not exist while the CLAP is deactivated, so the value was dropped outright. A host restoring a project sets kAudioUnitProperty_BypassEffect before it calls Initialize, so the plugin came up unbypassed while the host UI showed it bypassed.

Delivered two ways, because one is not enough: the idle tick handles it where there is time, and activateCLAP() handles it before clap_plugin.activate(), because Logic sets bypass and calls Initialize back-to-back with no idle tick in between.

Threading

clap_plugin_params.flush() must not run concurrently with process(), and is [active ? audio-thread : main-thread]. AUv3 gains a spin lock the way AUv2 already has one: the render block holds it around process() and every wrapper-issued flush holds it too. The activate/deactivate pairs are inside the same lock, so _initialized read under it is exactly "the plugin is active" and the thread identity cannot come from stale state — which also closes a pre-existing window where a render could reach process() between the adapter being published and start_processing().

The render block takes the lock after announcing itself in _renderInFlight, and deallocation drains that counter without holding the lock, so the two cannot form a cycle.

Known trade-off, shared with AUv2: the render thread can spin on a lock the main thread holds across plugin code. The three-blocks-without-a-render heuristic means a rendering host does not reach it, but it is the same pattern #550 raises against AUv2. If a better one is found, both formats should move together.

Verification

The reporter's six steps in #538 come with a repro plugin. Specifically:

  • Step 2 (plugin edit, block off): the host should catch up within roughly three block periods, without step 3.
  • Step 5 (host edit, block off): the plugin should reflect the new value on the same timescale, without step 6.
  • Normal rendering unaffected — automation timing, no glitches at block on/off transitions.
  • Bypass toggled with the block off reaches the plugin.

For AUv2: bypass an effect in Logic, save, reopen — it must start bypassed with the header button agreeing. Plus auval -v aufx, and automating a parameter on a muted track before unmuting.

Needs a macOS build and a run through the above before merging.

Not addressed

The mirror image on the AUv2 side: events queued on the process adapter between the last render and Cleanup or a restart still die with the adapter. AUv3 drains those after deactivate(); AUv2 could do the same. Separate change.

Closes #551.

SetParameter queued onto the process adapter, which does not exist while
the CLAP is deactivated, so the value was dropped. _requestedFlush was
still set and the idle tick then flushed an empty queue, and nothing
re-synced the AU element values into the plugin on activation - so the
value was gone for good. A host restoring a project sets
kAudioUnitProperty_BypassEffect, which arrives here through
SetBypassEffect, before it calls Initialize: the plugin came up unbypassed
while the host UI showed it bypassed.

The deactivated case now has an adapter to queue onto, and the queue is
delivered two ways because one is not enough. The idle tick delivers it
where there is time - clap/ext/params.h makes flush() [main-thread] while
the plugin is inactive, which is exactly this state - and activateCLAP()
delivers it before clap_plugin.activate(), because Logic sets bypass and
calls Initialize back to back with no idle tick in between and the
adapter is released at the end of activation. Flushing before activate()
also means the plugin comes up already holding the value rather than
being handed it in its first process() cycle.

deactivateCLAP() stands the adapter up as it drops the process adapter,
rather than leaving SetParameter to build it on demand. AUBase calls
SetParameter from ScheduleParameter(), which is the render thread, and
during a plugin-requested restart there is no process adapter for as long
as the plugin takes to rebuild its DSP - a render thread that had to
allocate the replacement would be allocating inside a real-time callback.
The remaining lazy path is before the first Initialize, where the host is
setting properties on an uninitialized unit from the main thread and no
render exists to call ScheduleParameter at all.

The comment on the deactivated flush claimed it already carried this
traffic. It does now.

Left alone, and the mirror image of this bug: events queued on the
process adapter between the last render and Cleanup or a restart still
die with the adapter.
Closes #538.

Parameter changes stopped moving in both directions whenever a host
stopped rendering without deallocating - Audio Hijack with its block
switched off, REAPER on an idle track - and resumed only when processing
did. In CLAP a plugin can push an output event only from inside process()
or flush(), and a host's parameter sets reach the plugin the same two
ways, so both directions stall together when neither call is coming.

Outbound, param_request_flush() only logged, so a value the plugin's own
editor changed sat in its queue indefinitely. It now records the request
and the main-queue idle timer services it.

Inbound, the observer and the bypass setter decided delivery from
renderResourcesAllocated. Apple documents that as resource-allocation
state, and a host may hold resources for a unit it is not rendering, so
it cannot answer whether a render will come. It now selects only the
*path* - adapter queue while resources are allocated, direct flush
otherwise - and the queue is drained by the idle tick when no render
drains it first.

What decides is the absence of renders rather than a flag: the render
block records that it ran, and the idle timer flushes once three block
periods have passed without one, sized from the sample rate and
maximumFramesToRender when resources are allocated. A rendering host
therefore never reaches the flush path.

clap_plugin_params.flush() must not run concurrently with process(), and
is [active ? audio-thread : main-thread]. A spin lock, as in AUv2, gives
the exclusion directly: the render block holds it around process(), and
every wrapper-issued flush holds it too. The activate and deactivate
pairs are inside the same lock, so _initialized read under it is exactly
"the plugin is active" and the thread identity cannot be chosen from
stale state - which also closes a window where a render could reach
process() between the adapter being published and start_processing(). The
render block takes the lock after announcing itself in _renderInFlight,
and deallocation drains that counter without holding the lock, so the two
cannot form a cycle.

Adds Clap::AUv3::ProcessAdapter::flush(): it drains the host queue into
input events, calls the plugin, and forwards parameter and gesture output
events to the automation sink. Note and MIDI output are dropped there on
purpose, since the AU MIDI output blocks may only be called from inside a
render. Deallocation's post-stop drain goes through it as well, so events
the plugin emits while being taken down still reach the host.
@singularpoint

Copy link
Copy Markdown

Thanks for picking this up! A few potential follow-ups from my investigation: respecting CLAP_PARAM_REQUIRES_PROCESS in active flushes, ensuring inactive flushes run on the actual main thread, and handling output queue overflow. For the spinlock trade-off already noted, os_unfair_lock could help mitigate priority inversion on resume.
These may fit better as separate changes to keep this fix focused.

@singularpoint

Copy link
Copy Markdown

I confirm the issue #538 is fixed by this PR both in MacOS and iOS.

@defiantnerd

Copy link
Copy Markdown
Collaborator Author

Thanks for bringing it up and that you already checked next before creating the issue. Also, thanks for the Repro.

SetParameter queues onto the process adapter, and deactivateCLAP() destroys
it. Anything the host set since the last render or flush is still in that
queue and goes with it -- silently, and after the host has been told the
value took.

The window is small but it is not rare: a host sets a parameter and then
deactivates the unit on a restart the plugin asked for, on a sample-rate or
block-size change, or on the way through a project close. In every one of
those the value is simply gone, and on the restart cases the plugin comes
back up in a state the host UI disagrees with.

This is the same loss the deactivated-state adapter already covers at the
other end of the window -- the span when there is no process adapter -- so
the backlog goes to the same place. The events move to the flush adapter as
the process adapter is dropped, and the deactivated path delivers them: the
next idle tick, or activateCLAP() ahead of clap_plugin.activate(), whichever
comes first. Both run on the main thread with the plugin inactive, which is
where clap_plugin_params.flush() is legal, so nothing is handed to the
plugin during teardown while it is still active.

Only parameter events move. Whatever else is queued belongs to a render that
is not going to happen, and a flush may not carry it in any case.

Their offsets go with the block they were offsets into, and dropping those is
not tidiness. The event sort keys on time first and only breaks ties on the
insertion index, so an offset left over from an earlier block would sort
behind a value the deactivated state queues later at time 0 -- the stale
value applied last, inverting exactly the last-write-wins this is meant to
preserve. Zeroed, they tie, and the tiebreak orders them as they arrived.

@baconpaul baconpaul left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm less familiar with the auv3 code but this seems to match gthe auv2 code in spirit so I'm fine for it to be merged if you're convinced it is correct @defiantnerd

AUv2: carry the parameter backlog across a deactivate
@defiantnerd
defiantnerd merged commit 5aaa0ec into next Sep 14, 2026
28 checks passed
@defiantnerd
defiantnerd deleted the fix-passive-parameter-sync branch September 14, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants