Keep parameters in sync while the host is not rendering (AUv3 #538, AUv2 #551) - #556
Conversation
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.
|
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. |
|
I confirm the issue #538 is fixed by this PR both in MacOS and iOS. |
|
Thanks for bringing it up and that you already checked |
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
left a comment
There was a problem hiding this comment.
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
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.
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.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)
SetParameterqueued onto the process adapter, which does not exist while the CLAP is deactivated, so the value was dropped outright. A host restoring a project setskAudioUnitProperty_BypassEffectbefore it callsInitialize, 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 beforeclap_plugin.activate(), because Logic sets bypass and callsInitializeback-to-back with no idle tick in between.Threading
clap_plugin_params.flush()must not run concurrently withprocess(), and is[active ? audio-thread : main-thread]. AUv3 gains a spin lock the way AUv2 already has one: the render block holds it aroundprocess()and every wrapper-issued flush holds it too. The activate/deactivate pairs are inside the same lock, so_initializedread 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 reachprocess()between the adapter being published andstart_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:
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
Cleanupor a restart still die with the adapter. AUv3 drains those afterdeactivate(); AUv2 could do the same. Separate change.