From 6a8280d6c3ebf6eb74f5c2d78cdc99893c3762b8 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:17:15 +0200 Subject: [PATCH 1/3] AUv2: deliver host parameter changes made while the CLAP is deactivated 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. --- src/detail/auv2/auv2_base_classes.h | 13 +++- src/wrapasauv2.cpp | 111 ++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/src/detail/auv2/auv2_base_classes.h b/src/detail/auv2/auv2_base_classes.h index 34aafe44..67f85074 100644 --- a/src/detail/auv2/auv2_base_classes.h +++ b/src/detail/auv2/auv2_base_classes.h @@ -834,7 +834,11 @@ class WrapAsAUV2 : public ausdk::AUBase, // caller must treat that as a failed initialization. bool activateCLAP(); void deactivateCLAP(); - // parameter-only round trip on a throwaway process adapter, for when no render + // the process adapter used while the CLAP is deactivated: SetParameter queues + // the host's values on it and flushParameters() delivers them. Built on first + // use; call under _processLock. Null when the plugin has no params extension. + Clap::AUv2::ProcessAdapter *ensureFlushAdapter(); + // parameter-only round trip on the deactivated-case adapter, for when no render // is running to carry the events. Only legal while the CLAP is deactivated. void flushParameters(); // the AU-level half of the teardown, which an internal restart must not do @@ -857,8 +861,11 @@ class WrapAsAUV2 : public ausdk::AUBase, std::shared_ptr _plugin = nullptr; std::unique_ptr _processAdapter; - // Only for the deactivated-plugin flush, where _processAdapter does not - // exist. Lives across flushes so gestures pair up; see flushParameters(). + // Only while the plugin is deactivated, where _processAdapter does not exist: + // SetParameter queues the host's values on it (a host sets bypass and + // parameters on a unit before it calls Initialize), and the idle flush or + // activateCLAP() -- whichever comes first -- delivers them. Lives across + // flushes so gestures pair up; see ensureFlushAdapter(). std::unique_ptr _flushAdapter; std::atomic _initialized = false; diff --git a/src/wrapasauv2.cpp b/src/wrapasauv2.cpp index 63f3b8a2..f94388e4 100644 --- a/src/wrapasauv2.cpp +++ b/src/wrapasauv2.cpp @@ -772,15 +772,31 @@ OSStatus WrapAsAUV2::SetParameter(AudioUnitParameterID inID, AudioUnitScope inSc // this from ScheduleParameter(), i.e. on the render thread but outside the // render, so without this the queue would have two writers. ClapWrapper::detail::shared::SpinLockGuard processGuard(_processLock); + auto ¶m = p->second.get()->info(); if (_processAdapter) { - auto ¶m = p->second.get()->info(); _processAdapter->addParameterEvent(param, inValue, inBufferOffsetInFrames); } + else if (auto *flushAdapter = ensureFlushAdapter()) + { + // The CLAP is deactivated, so there is no process adapter -- but the + // value is no less real for that. A host restoring a project sets + // kAudioUnitProperty_BypassEffect (which lands here through + // SetBypassEffect) and parameter values on the unit *before* it calls + // Initialize, and nothing re-syncs the AU element values into the + // plugin afterwards; dropped here, the value is gone for good, with + // the host UI showing a state the plugin is not in. clap/ext/params.h + // has exactly this case covered: flush() is [main-thread] while the + // plugin is inactive. So the event goes to the adapter the + // deactivated-state flush uses, and is handed over on the next idle + // tick or at activation, whichever comes first (see activateCLAP()). + flushAdapter->addParameterEvent(param, inValue, inBufferOffsetInFrames); + } } // Nothing may ever render this: on an idle track process() is not coming, - // and then onIdle() is the only thing that will hand it to the plugin. + // and on a deactivated plugin it cannot come; either way onIdle() is the + // only thing that will hand it to the plugin. _requestedFlush = true; } return AUBase::SetParameter(inID, inScope, inElement, inValue, inBufferOffsetInFrames); @@ -1507,13 +1523,34 @@ bool WrapAsAUV2::activateCLAP() // the adapter checks the pointer under it, and must not find one that is // half set up (setupProcessing clears the event queue as it goes). ClapWrapper::detail::shared::SpinLockGuard processGuard(_processLock); + + // Whatever the host set while the plugin was deactivated is queued on the + // flush adapter (see SetParameter), and the idle tick may not have had a + // chance to deliver it: a host restoring a project sets + // kAudioUnitProperty_BypassEffect and then calls Initialize right behind + // it, with no idle tick in between. Deliver it now, while the plugin is + // still inactive and clap_plugin_params.flush() is therefore + // [main-thread] -- the thread every caller of this function guarantees + // (Initialize(), and the restart and request_process paths in onIdle()). + // Nothing else can be inside the plugin: _initialized is false, so + // Render() returns before touching it, and the idle flush is the caller + // or holds this same lock. Ordered before activate() on purpose: the + // plugin then activates already holding the value, instead of being + // handed it in its first process() cycle. + if (_flushAdapter) + { + auto guarantee_mainthread = _plugin->AlwaysMainThread(); + _flushAdapter->flush(); + } + if (!_processAdapter) _processAdapter = std::make_unique(); _processAdapter->setupProcessing(Inputs(), Outputs(), _plugin->_plugin, _plugin->_ext._params, this, &_parametertree, this, maxSampleFrames, _midi_preferred_dialect, _midi_supported_dialects, clapAudioInputs, clapAudioOutputs); - // The deactivated-state flush adapter has no further use, and the gestures - // it was tracking belong to the real one now. + // The deactivated-state flush adapter has no further use: its parameter + // events have just been delivered, and the gestures it was tracking belong + // to the real one now. _flushAdapter.reset(); } @@ -1534,6 +1571,16 @@ void WrapAsAUV2::deactivateCLAP() ClapWrapper::detail::shared::SpinLockGuard processGuard(_processLock); _initialized = false; _processAdapter.reset(); + + // Stand the deactivated-state adapter up here, on the main thread, rather + // than leaving SetParameter to build it on demand. AUBase calls + // SetParameter from ScheduleParameter(), which is the render thread, and + // between this reset and the matching activateCLAP() there is no process + // adapter for it to queue on -- a window 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; finding one already + // here makes it a queue push and nothing more. + ensureFlushAdapter(); } _plugin->stop_processing(); _plugin->deactivate(); @@ -1866,7 +1913,8 @@ void WrapAsAUV2::onIdle() // In CLAP a plugin can only push an output event -- a value its own editor // changed, the gesture around it -- from inside process() or flush(), and the // host's own parameter sets only reach the plugin the same way (SetParameter - // queues them on the process adapter). Both directions therefore stop dead + // queues them on the process adapter while the plugin is active, and on the + // flush adapter while it is not). Both directions therefore stop dead // whenever the host stops rendering, and AU hosts do stop: Logic will not run // a track it knows carries no signal, and an initialized unit can sit there // for minutes without a single Render() call. The VST3 SDK's AU wrapper and @@ -1921,7 +1969,9 @@ void WrapAsAUV2::onIdle() else { // Deactivated: the real adapter does not exist, and flush() is - // [main-thread] here. + // [main-thread] here. This is also the path that carries the host's + // parameter sets on an uninitialized unit -- SetParameter queues them on + // the flush adapter -- so it is a real delivery, not a courtesy call. auto guarantee_mainthread = _plugin->AlwaysMainThread(); flushParameters(); } @@ -1931,21 +1981,29 @@ void WrapAsAUV2::onIdle() pushQueuedEventsToHost(); } -// Builds a throwaway process adapter to flush against, for the deactivated case -// only: the real one lives between activateCLAP() and deactivateCLAP(), and -// clap_plugin_params.flush() is [main-thread] exactly while the plugin is -// inactive. Callers check _initialized under _processLock. -void WrapAsAUV2::flushParameters() +// The process adapter for the deactivated case: the real one lives between +// activateCLAP() and deactivateCLAP(), and this one is what SetParameter queues +// onto and flushParameters() flushes while the CLAP is inactive. Built on first +// use, and kept between flushes rather than built per call, the way the VST3 +// wrapper builds its throwaway: a gesture the plugin opens in one deactivated +// flush and closes in the next has to find the same adapter, because that is +// where the open was recorded -- a close arriving at a fresh one is dropped, and +// the host stays armed on the parameter. activateCLAP() flushes and releases +// it. No audio is involved: a zero numMaxSamples skips the silent-stream +// buffers, and the plugin is handed no audio ports. +// Callers hold _processLock. Null when the plugin has no params extension, +// in which case there is nothing to queue and nothing to flush. +// +// Allocates on first use, so it must not be reached first from a render thread. +// It cannot be: deactivateCLAP() builds it as it drops the process adapter, so +// the restart window always has one ready, and the only other moment without a +// process adapter 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. +Clap::AUv2::ProcessAdapter *WrapAsAUV2::ensureFlushAdapter() { - if (!_plugin || !_plugin->_ext._params) return; - - // Kept between flushes rather than built per call, the way the VST3 wrapper - // builds its throwaway: a gesture the plugin opens in one deactivated flush - // and closes in the next has to find the same adapter, because that is where - // the open was recorded -- a close arriving at a fresh one is dropped, and - // the host stays armed on the parameter. activateCLAP() releases it. - // No audio is involved: a zero numMaxSamples skips the silent-stream buffers, - // and the plugin is handed no audio ports. + if (!_plugin || !_plugin->_ext._params) return nullptr; + if (!_flushAdapter) { _flushAdapter = std::make_unique(); @@ -1953,7 +2011,18 @@ void WrapAsAUV2::flushParameters() &_parametertree, this, 0, _midi_preferred_dialect, _midi_supported_dialects, 0, 0); } - _flushAdapter->flush(); + return _flushAdapter.get(); +} + +// Flushes the deactivated-case adapter: clap_plugin_params.flush() is +// [main-thread] exactly while the plugin is inactive. Callers check +// _initialized under _processLock. +void WrapAsAUV2::flushParameters() +{ + if (auto *flushAdapter = ensureFlushAdapter()) + { + flushAdapter->flush(); + } } OSStatus WrapAsAUV2::SaveState(CFPropertyListRef *ptPList) From 26399df66f63147592a7c69133cde26245ab2723 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:17:32 +0200 Subject: [PATCH 2/3] AUv3: keep parameters in sync while the host is not rendering 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. --- src/detail/auv3/auv3_audiounit.mm | 370 +++++++++++++++++++++++------- src/detail/auv3/process.h | 12 +- src/detail/auv3/process.mm | 61 ++++- 3 files changed, 356 insertions(+), 87 deletions(-) diff --git a/src/detail/auv3/auv3_audiounit.mm b/src/detail/auv3/auv3_audiounit.mm index 6f1dc567..d4636c43 100644 --- a/src/detail/auv3/auv3_audiounit.mm +++ b/src/detail/auv3/auv3_audiounit.mm @@ -10,10 +10,13 @@ #include "detail/clap/fsutil.h" #include "detail/os/osutil.h" #include "detail/shared/fixedqueue.h" +#include "detail/shared/spinlock.h" #include "detail/clap/automation.h" #include #include +#include +#include #include #include #include @@ -142,6 +145,34 @@ - (void)_applyGUISizeWidth:(uint32_t)width height:(uint32_t)height; std::atomic_bool _requestMarkDirty{false}; dispatch_source_t _idleTimer = nullptr; + // "there are parameter events to move": set by param_request_flush() for + // the plugin's direction and by the parameter observer / bypass setter for + // the host's, serviced by the idle timer. See serviceFlushRequest(). + std::atomic_bool _requestedFlush{false}; + // set by the render block, cleared by the idle timer: tells the tick whether + // a render has carried the parameter traffic since it last looked. + std::atomic_bool _renderedSinceIdle{false}; + // consecutive idle ticks that saw no render, saturating at the point where + // the idle tick takes over the flushing. Only the idle timer touches it. + uint32_t _idleTicksSinceRender = 0; + // how many of those ticks make a paused host, sized from the block period + // in allocateRenderResourcesAndReturnError:. See serviceFlushRequest(). + static constexpr double kIdleTickMs = 10.0; + static constexpr uint32_t kMinIdleTicksBeforeFlush = 5; + std::atomic _idleTicksBeforeFlush{kMinIdleTicksBeforeFlush}; + + // The one lock that keeps clap_plugin_params.flush() and clap_plugin.process() + // apart, which CLAP requires (clap/ext/params.h: flush "must not be called + // concurrently to clap_plugin->process()"). Held by the render block around + // process(), by every flush this wrapper issues, and by allocate/deallocate + // around the activate/start_processing and stop_processing/deactivate + // pairs so that, under it, _initialized is exactly "the plugin is active" — + // which is what decides whether a flush must claim the audio thread or the + // main thread. A spin lock because the render thread must never block on + // a kernel primitive; the idle flush only runs once the host has stopped + // rendering, so the render side effectively never finds it taken. + ClapWrapper::detail::shared::SpinLock _processLock; + // Back-reference to the ObjC audio unit (weak to avoid retain cycle) __weak ClapAUv3AudioUnit *_audioUnit = nil; @@ -211,8 +242,10 @@ void request_process() override { // The AUv3 host owns the render resources: start_processing() happens in // allocateRenderResourcesAndReturnError:, which only the host calls. There - // is nothing to wake from this side. + // is nothing to wake from this side — but whatever parameter traffic the + // request was really about can still be moved by a flush, so ask for one. AUV3LOG("IHost::request_process() called"); + _requestedFlush = true; } void request_callback() override @@ -235,6 +268,11 @@ void startIdleTimer() auto *processing = &_initialized; // true between start_processing/stop_processing auto *self = this; dispatch_source_set_event_handler(_idleTimer, ^{ + // Move parameter events in both directions when the host has stopped + // rendering. Runs first so that what the flush pulls out of the plugin + // reaches the host in this tick's drain rather than the next. + self->serviceFlushRequest(); + // Drain the parameter automation queue (Touch/Value/Release → host). // This is safe even while processing — it only touches AUParameter // objects on the main queue, no CLAP plugin calls. @@ -485,7 +523,13 @@ void param_clear(clap_id param, clap_param_clear_flags flags) override void param_request_flush() override { - AUV3LOG("IHost::param_request_flush() called"); + // May arrive at any moment and in any state, active or not: it says the + // plugin has parameter events (its editor moved a knob), not that it has + // stopped processing. clap_host_params.request_flush() obliges the host + // to schedule process() or flush(); the render does the former while + // the host is rendering, and the idle timer does the latter once it has + // seen that no render is coming (see serviceFlushRequest()). + _requestedFlush = true; } void latency_changed() override @@ -748,10 +792,143 @@ void drainParameterQueue() } } - // Deliver a single parameter value to the plugin via params->flush(). - // Only legal while the plugin is NOT processing. The single construction - // point for the one-shot event list used by the observer, the bypass - // setter, and the post-deallocate queue drain. + // Output sink for the flushes this wrapper issues outside the process + // adapter. The plugin may answer a flush with parameter events of its own + // (a value its editor changed, the gesture around it, a dependent + // parameter it moved in response) — they go down the same path a render's + // would, so the host sees them either way. + static bool flushOutputTryPush(const clap_output_events_t *list, const clap_event_header_t *ev) + { + auto *self = static_cast(list->ctx); + if (ev->space_id != CLAP_CORE_EVENT_SPACE_ID) return true; + switch (ev->type) + { + case CLAP_EVENT_PARAM_VALUE: + if (ev->size >= sizeof(clap_event_param_value_t)) + self->onPerformEdit(reinterpret_cast(ev)); + break; + case CLAP_EVENT_PARAM_GESTURE_BEGIN: + if (ev->size >= sizeof(clap_event_param_gesture_t)) + self->onBeginEdit(reinterpret_cast(ev)->param_id); + break; + case CLAP_EVENT_PARAM_GESTURE_END: + if (ev->size >= sizeof(clap_event_param_gesture_t)) + self->onEndEdit(reinterpret_cast(ev)->param_id); + break; + default: + break; + } + return true; + } + + // Calls clap_plugin_params.flush() with the given input list, excluded from + // process() by _processLock and on the thread identity CLAP demands for the + // plugin's current state: [active ? audio-thread : main-thread]. Under the + // lock _initialized is exactly "active" — allocate publishes it after + // start_processing and deallocate clears it after deactivate, both while + // holding the lock — so the decision cannot be stale. + void flushEvents(const clap_input_events_t *in_events) + { + ClapWrapper::detail::shared::SpinLockGuard processGuard(_processLock); + flushEventsLocked(in_events); + } + + // The body of flushEvents(); the caller holds _processLock. + void flushEventsLocked(const clap_input_events_t *in_events) + { + if (!_plugin || !_plugin->_ext._params) return; + + clap_output_events_t out_events = {}; + out_events.ctx = this; + out_events.try_push = flushOutputTryPush; + + if (_initialized) + { + auto audioGuard = _plugin->AlwaysAudioThread(); + _plugin->_ext._params->flush(_plugin->_plugin, in_events, &out_events); + } + else + { + auto mainGuard = _plugin->AlwaysMainThread(); + _plugin->_ext._params->flush(_plugin->_plugin, in_events, &out_events); + } + } + + // The parameter flush, run from the idle timer on the main queue. + // + // In CLAP a plugin can only push an output event — a value its own editor + // changed, the gesture around it — from inside process() or flush(), and + // the host's own parameter sets only reach the plugin the same way (the + // observer queues them on the process adapter while render resources are + // allocated). Both directions therefore stop dead whenever the host stops + // rendering, and AUv3 hosts do stop without deallocating: Audio Hijack + // keeps the resources of a switched-off block, REAPER leaves an idle track's + // unit initialized, and Apple documents renderResourcesAllocated as + // resource state only, never as a promise that render calls will follow. + // So the flag cannot decide whether a render will deliver; only the absence + // of renders can, and this is the one place that observes it. + // + // One empty tick is not proof the host has paused, and guessing wrong costs + // something: the host schedules its automation through the render event + // list, and a flush that beat the next render would deliver the queued + // values out of order with it. How long to wait cannot be a constant — at + // 4096 frames and 44.1kHz a block is 93ms, so a host rendering perfectly + // normally leaves gaps longer than any small number of 10ms ticks. + // allocateRenderResources sizes the wait at three blocks, which no + // rendering host produces; once one really has paused, the counter stays + // saturated and a request is served on the very next tick. With the plugin + // deactivated there is no render to wait for, and none is waited for. + void serviceFlushRequest() + { + if (!_plugin) return; + + if (_renderedSinceIdle.exchange(false)) + { + _idleTicksSinceRender = 0; + } + else if (_idleTicksSinceRender < _idleTicksBeforeFlush) + { + ++_idleTicksSinceRender; + } + + if (!_requestedFlush) return; + if (_initialized && _idleTicksSinceRender < _idleTicksBeforeFlush) return; + + // Cleared before the work, not after: a request that arrives while the + // plugin is inside flush() is about events this flush cannot have seen, + // and has to survive into the next tick. + _requestedFlush = false; + + ClapWrapper::detail::shared::SpinLockGuard processGuard(_processLock); + if (_initialized && _processAdapter) + { + // Active: the queued host changes live in the adapter, and flush() is + // [audio-thread]; the lock is what lets the main queue stand in for it. + auto audioGuard = _plugin->AlwaysAudioThread(); + _processAdapter->flush(); + } + else + { + // Deactivated: host changes were already flushed one by one as they + // arrived (flushParamValueWithCookie), so only the plugin's direction + // is owed — an empty input list gives it somewhere to push. flush() is + // [main-thread] here, which this queue is. + clap_input_events_t in_events = {}; + in_events.ctx = nullptr; + in_events.size = [](const clap_input_events_t *) -> uint32_t { return 0; }; + in_events.get = [](const clap_input_events_t *, uint32_t) -> const clap_event_header_t * + { return nullptr; }; + flushEventsLocked(&in_events); + } + } + + // Deliver a single parameter value to the plugin via params->flush(). The + // path the observer and the bypass setter take while render resources are + // not allocated: no render can carry the change, and a host that sets a + // value and reads fullState straight after must find it in the plugin, so + // it is not deferred to the idle tick. Excluded from process() by + // flushEvents(), which also covers the narrow window in which allocation + // completes between the observer's routing decision and this call. void flushParamValueWithCookie(clap_id id, double value, void *cookie) { if (!_plugin || !_plugin->_ext._params) return; @@ -777,24 +954,7 @@ void flushParamValueWithCookie(clap_id id, double value, void *cookie) in_events.get = [](const clap_input_events_t *list, uint32_t) -> const clap_event_header_t * { return *static_cast(list->ctx); }; - clap_output_events_t out_events = {}; - out_events.ctx = nullptr; - out_events.try_push = [](const clap_output_events_t *, const clap_event_header_t *) -> bool - { return true; }; - - auto mainGuard = _plugin->AlwaysMainThread(); - _plugin->_ext._params->flush(_plugin->_plugin, &in_events, &out_events); - } - - void flushParamValue(clap_id id, double value) - { - void *cookie = nullptr; - { - std::lock_guard lock(_paramCacheMutex); - auto it = _paramCookieCache.find(id); - if (it != _paramCookieCache.end()) cookie = it->second; - } - flushParamValueWithCookie(id, value, cookie); + flushEvents(&in_events); } // --- IPlugObject --- @@ -1166,14 +1326,19 @@ - (void)_wireParameterObserver if (s_suppressParamObserverEcho) return; // Always update the cache (and fetch the cookie in the same lock scope). - // While rendering, also queue the change for the render thread: - // flush() is forbidden while the plugin is processing and the adapter's - // event vectors are render-thread-owned, so the SPSC queue (producers - // serialized by this mutex) is the only legal delivery path — it is - // drained as input events at the top of the next render cycle. The - // rendering decision is made INSIDE the lock: allocate/deallocate flip - // _renderResourcesAllocated under the same mutex, so we can never - // flush while processing or queue on a freed adapter. + // While render resources are allocated, queue the change for the render + // thread: the adapter's event vectors are render-thread-owned, so the + // SPSC queue (producers serialized by this mutex) is the delivery path + // — it is drained as input events at the top of the next render cycle, + // or by the idle timer's flush if no render comes. The routing decision + // is made INSIDE the lock: allocate/deallocate flip + // _renderResourcesAllocated under the same mutex, so we can never queue + // on a freed adapter. + // + // _renderResourcesAllocated only chooses the route, never promises + // delivery: Apple defines it as resource state, and Audio Hijack keeps a + // switched-off block's resources allocated with no render running. The + // flush request below is what guarantees the change arrives regardless. clap_id pid = (clap_id)param.address; void *cookie = nullptr; bool queuedForRender = false; @@ -1192,9 +1357,16 @@ - (void)_wireParameterObserver queuedForRender = true; } } - if (queuedForRender) return; + if (queuedForRender) + { + // Nothing may ever render this: on a paused host process() is not + // coming, and then the idle timer is the only thing that will hand it + // to the plugin (see serviceFlushRequest()). + strongSelf->_impl->_requestedFlush = true; + return; + } - // Non-realtime path: push directly to the CLAP plugin via flush. + // Deactivated: push directly to the CLAP plugin via flush. strongSelf->_impl->flushParamValueWithCookie(pid, (double)value, cookie); }; @@ -1583,23 +1755,47 @@ - (BOOL)allocateRenderResourcesAndReturnError:(NSError **)outError _impl->_processAdapter->hostMIDIProtocol = self.hostMIDIProtocol; } - // Publish the adapter for the render block and flip the flag under the - // cache mutex BEFORE the plugin may start processing: producers - // (implementorValueObserver, bypass setter) must switch from the flush - // path to the render queue path first — flush during processing violates - // the CLAP contract. Queued changes wait in the adapter until the first - // render cycle. The mutex pairs with the producers' flag check. - _impl->_processAdapterLive.store(_impl->_processAdapter.get()); + // How long the idle timer waits before it decides the host has paused the + // render. A fixed number of ticks cannot work: at 4096 frames and 44.1kHz a + // block is 93ms, so a host that is rendering perfectly normally leaves gaps + // longer than any small constant, and the idle tick would flush inside + // every one of them. Three blocks is a gap no rendering host produces. { - std::lock_guard lock(_impl->_paramCacheMutex); - _renderResourcesAllocated = YES; + const double blockMs = 1000.0 * (double)self.maximumFramesToRender / std::max(sampleRate, 1.0); + const auto ticks = (uint32_t)std::ceil(3.0 * blockMs / _impl->kIdleTickMs); + _impl->_idleTicksBeforeFlush = std::max(_impl->kMinIdleTicksBeforeFlush, ticks); } - // Activate the CLAP plugin - AUV3LOG("allocateRenderResources: calling activate()"); - _impl->_plugin->activate(); + { + // Under _processLock from here to _initialized: no flush can slip in + // between activate() and start_processing() and claim the wrong thread + // for an already-active plugin, and no render can reach process() before + // start_processing() (the adapter is published before activation). + ClapWrapper::detail::shared::SpinLockGuard processGuard(_impl->_processLock); - // Re-cache latency — the plugin may have set it during activation + // Publish the adapter for the render block and flip the flag under the + // cache mutex BEFORE the plugin may start processing: producers + // (implementorValueObserver, bypass setter) must switch from the flush + // path to the render queue path first — flush during processing violates + // the CLAP contract. Queued changes wait in the adapter until the first + // render cycle. The mutex pairs with the producers' flag check. + _impl->_processAdapterLive.store(_impl->_processAdapter.get()); + { + std::lock_guard lock(_impl->_paramCacheMutex); + _renderResourcesAllocated = YES; + } + + // Activate the CLAP plugin + AUV3LOG("allocateRenderResources: calling activate()"); + _impl->_plugin->activate(); + + AUV3LOG("allocateRenderResources: calling start_processing()"); + _impl->_plugin->start_processing(); + _impl->_initialized = true; + } + + // Re-cache latency — the plugin may have set it during activation. Outside + // the lock: the KVO runs the host's listeners synchronously. if (_impl->_plugin->_ext._latency) { uint32_t newLatency = _impl->_plugin->_ext._latency->get(_impl->_plugin->_plugin); @@ -1612,10 +1808,6 @@ - (BOOL)allocateRenderResourcesAndReturnError:(NSError **)outError } } - AUV3LOG("allocateRenderResources: calling start_processing()"); - _impl->_plugin->start_processing(); - _impl->_initialized = true; - AUV3LOG("allocateRenderResources: completed successfully"); return YES; } @@ -1637,18 +1829,26 @@ - (void)deallocateRenderResources } } - if (_impl && _impl->_plugin && _impl->_initialized) - { - auto guarantee_mainthread = _impl->_plugin->AlwaysMainThread(); - AUV3LOG("deallocateRenderResources: calling stop_processing()"); - _impl->_plugin->stop_processing(); - AUV3LOG("deallocateRenderResources: calling deactivate()"); - _impl->_plugin->deactivate(); - _impl->_initialized = false; - } - if (_impl) { + // Under _processLock from stop_processing() to the adapter reset: the + // idle timer's flush checks _initialized and _processAdapter under the + // same lock, so it can neither claim the audio thread for a plugin that + // is being deactivated nor flush through an adapter that is being freed. + // Taken only after the render handshake above completed, so a render + // that holds the lock is never waited for while _renderInFlight is held. + ClapWrapper::detail::shared::SpinLockGuard processGuard(_impl->_processLock); + + if (_impl->_plugin && _impl->_initialized) + { + auto guarantee_mainthread = _impl->_plugin->AlwaysMainThread(); + AUV3LOG("deallocateRenderResources: calling stop_processing()"); + _impl->_plugin->stop_processing(); + AUV3LOG("deallocateRenderResources: calling deactivate()"); + _impl->_plugin->deactivate(); + _impl->_initialized = false; + } + // Producers (implementorValueObserver, bypass setter) check this flag // and touch the adapter under _paramCacheMutex — flip it under the // same mutex so a producer can never race the adapter reset below. @@ -1660,20 +1860,18 @@ - (void)deallocateRenderResources } // Changes parked in the render queue while the last cycles ran would - // otherwise be lost (the cache and host UI already show them) — - // deliver them via flush, which is legal now that processing stopped. - if (_impl->_processAdapter) + // otherwise be lost (the cache and host UI already show them) — deliver + // them via flush, which is [main-thread] now that the plugin is + // deactivated, and hand the host whatever the plugin still had to say. + if (_impl->_processAdapter && _impl->_plugin) { - Clap::AUv3::ProcessAdapter::QueuedParamChange qpc; - while (_impl->_processAdapter->dequeueParameterChange(qpc)) - { - _impl->flushParamValue(qpc.id, qpc.value); - } + auto guarantee_mainthread = _impl->_plugin->AlwaysMainThread(); + _impl->_processAdapter->flush(); } - } - AUV3LOG("deallocateRenderResources: resetting process adapter"); - _impl->_processAdapter.reset(); + AUV3LOG("deallocateRenderResources: resetting process adapter"); + _impl->_processAdapter.reset(); + } AUV3LOG("deallocateRenderResources: calling [super deallocateRenderResources]"); [super deallocateRenderResources]; @@ -1712,10 +1910,19 @@ - (AUInternalRenderBlock)internalRenderBlock // thread during init, so the default heuristic is wrong. AUAudioUnitStatus status = kAudioUnitErr_Uninitialized; { + // Excludes the idle timer's flush (see serviceFlushRequest()), which + // only runs once three blocks have passed without a render — so in a + // rendering host this lock is free. Taken after the _renderInFlight + // announcement so deallocation, which waits on that counter without + // holding the lock, cannot form a cycle with a render waiting here. + ClapWrapper::detail::shared::SpinLockGuard processGuard(impl->_processLock); auto audioGuard = impl->_plugin->AlwaysAudioThread(); status = adapter->process(actionFlags, timestamp, frameCount, outputBusNumber, outputData, realtimeEventListHead, pullInputBlock); } + // This render carried whatever was queued in either direction, so the + // next idle tick has nothing to make up for. + impl->_renderedSinceIdle = true; impl->_renderInFlight.fetch_sub(1); // Do NOT dispatch on_main_thread() from the render block. Surge XT's @@ -1915,11 +2122,12 @@ - (void)setShouldBypassEffect:(BOOL)shouldBypassEffect double newValue = shouldBypassEffect ? 1.0 : 0.0; - // Update cache (and fetch the cookie in the same lock scope). While - // rendering, route the change through the render thread's queue — - // flush() is forbidden while the plugin is processing. The rendering - // decision is made INSIDE the lock (paired with allocate/deallocate - // flipping the flag under the same mutex). + // Update cache (and fetch the cookie in the same lock scope). While render + // resources are allocated, route the change through the render thread's + // queue, same as implementorValueObserver — and ask for a flush, since a + // paused host will never render it. The routing decision is made INSIDE + // the lock (paired with allocate/deallocate flipping the flag under the + // same mutex). void *cookie = nullptr; bool queuedForRender = false; { @@ -1938,9 +2146,13 @@ - (void)setShouldBypassEffect:(BOOL)shouldBypassEffect } } - // Push to the CLAP plugin via params->flush() (only legal while not processing) - if (!queuedForRender) + if (queuedForRender) + { + _impl->_requestedFlush = true; + } + else { + // Deactivated: push to the CLAP plugin via params->flush() directly. _impl->flushParamValueWithCookie(_impl->_bypassParamId, newValue, cookie); } diff --git a/src/detail/auv3/process.h b/src/detail/auv3/process.h index b2fb67ca..04302fcb 100644 --- a/src/detail/auv3/process.h +++ b/src/detail/auv3/process.h @@ -86,10 +86,14 @@ class ProcessAdapter }; void queueParameterChange(clap_id paramId, double value); - // Drain the host-change queue outside rendering (the wrapper calls this - // after stop_processing to flush changes that were parked while the last - // cycles ran). Only legal when the render thread is quiesced. - bool dequeueParameterChange(QueuedParamChange &out); + // Parameter-only round trip for when no render is coming to carry the + // events: hands the queued host changes to the plugin through + // clap_plugin_params.flush() and forwards the plugin's parameter output + // events to the automation sink, exactly as process() would. Legal only + // when excluded from process() — the caller holds the wrapper's process + // lock and claims the thread identity CLAP demands for the plugin's + // current state ([active ? audio-thread : main-thread]). + void flush(); // MIDI output event block (set by the AU host) — legacy 3-byte MIDI 1.0 path AUMIDIOutputEventBlock __nullable midiOutputEventBlock; diff --git a/src/detail/auv3/process.mm b/src/detail/auv3/process.mm index a3f02c33..7b24f7ad 100644 --- a/src/detail/auv3/process.mm +++ b/src/detail/auv3/process.mm @@ -985,11 +985,64 @@ inline clap_sectime doubleToSecTime(double t) _hostParamChangesCount.fetch_add(1); } -bool ProcessAdapter::dequeueParameterChange(QueuedParamChange &out) +// See the declaration. This is the same top-of-cycle drain process() does, minus +// the audio: a host that has allocated render resources but paused the render +// (Audio Hijack with the block switched off, REAPER with the track idle) still +// owes the plugin its parameter changes and still owes the host the plugin's, +// and CLAP allows both only inside process() or flush(). +void ProcessAdapter::flush() { - if (!_hostParamChanges.pop(out)) return false; - _hostParamChangesCount.fetch_sub(1); - return true; + if (!_ext_params) return; + + // Whatever the last render cycle left in the input list was delivered by + // that cycle; only the parked host changes are pending. + _events.clear(); + _eventindices.clear(); + { + QueuedParamChange qpc; + while (_hostParamChanges.pop(qpc)) + { + _hostParamChangesCount.fetch_sub(1); + addParameterEvent(qpc.id, qpc.value, 0); + } + } + sortEventIndices(); + + _ext_params->flush(_plugin, &_in_events, &_out_events); + + // Only parameter events can leave a flush. MIDI and note output the plugin + // pushed here has no render to ride on — the AU MIDI output blocks may only + // be called from inside the render — so it is dropped rather than delivered + // out of time. + for (auto &evt : _outevents) + { + if (evt.header.space_id != CLAP_CORE_EVENT_SPACE_ID) continue; + switch (evt.header.type) + { + case CLAP_EVENT_PARAM_VALUE: + if (_automation) _automation->onPerformEdit(&evt.param); + break; + case CLAP_EVENT_PARAM_GESTURE_BEGIN: + { + auto *ge = (clap_event_param_gesture *)&evt; + if (_automation) _automation->onBeginEdit(ge->param_id); + break; + } + case CLAP_EVENT_PARAM_GESTURE_END: + { + auto *ge = (clap_event_param_gesture *)&evt; + if (_automation) _automation->onEndEdit(ge->param_id); + break; + } + default: + break; + } + } + _outevents.clear(); + _sysexOutBuffers.reset(); + + _events.clear(); + _eventindices.clear(); } void ProcessAdapter::addParameterEvent(clap_id paramId, double value, uint32_t sampleOffset) From 1df2279d30ccb5141b01f118cb0c628959bc9a63 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:29:34 +0200 Subject: [PATCH 3/3] AUv2: carry the parameter backlog across a deactivate 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. --- src/detail/auv2/process.cpp | 51 +++++++++++++++++++++++++++++++++++++ src/detail/auv2/process.h | 4 +++ src/wrapasauv2.cpp | 27 ++++++++++++++++++-- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/detail/auv2/process.cpp b/src/detail/auv2/process.cpp index 50ed81c8..ebe3c4a3 100644 --- a/src/detail/auv2/process.cpp +++ b/src/detail/auv2/process.cpp @@ -852,4 +852,55 @@ void ProcessAdapter::addParameterEvent(const clap_param_info_t &info, double val this->_eventindices.emplace_back(this->_events.size()); this->_events.emplace_back(n); } + +// Moves the queued parameter events onto another adapter. This exists for one +// moment: deactivateCLAP() drops the process adapter, and anything the host set +// since the last render or flush is still sitting in it. Those values are as +// real as any other -- a host that sets a parameter and then deactivates the +// unit (a restart, a format change, a project close-and-reopen) has been told +// nothing went wrong -- so they move to the adapter the deactivated state +// flushes from rather than dying with this one. +// +// Only parameter events move. Everything else queued here belongs to a render +// that is not going to happen now, and a flush may not carry it anyway. +// +// The offsets go with the block they were offsets into, and dropping them is +// not tidiness: sortEventIndices() sorts on time first, and only breaks ties on +// the insertion index. Left as they were, an event stamped into some earlier +// block would sort *after* a value the deactivated state queues later at time +// 0 -- the stale value applied last, which is the inversion this transfer +// exists to avoid. At 0 they all tie, and the tiebreak then orders them the way +// they arrived: these first, anything queued afterwards over the top. +// +// Order follows position in _events, because flush() rebuilds _eventindices +// over it before sorting. Keeping the index list in step here is for a +// process() that never comes on the adapter these are going to. +size_t ProcessAdapter::transferPendingParametersTo(ProcessAdapter &other) +{ + if (&other == this) return 0; + + size_t moved = 0; + size_t kept = 0; + for (size_t i = 0; i < _events.size(); ++i) + { + if (isParameterEvent(_events[i].header.type)) + { + clap_multi_event_t n = _events[i]; + n.header.time = 0; + other._eventindices.emplace_back(other._events.size()); + other._events.emplace_back(n); + ++moved; + } + else + { + _events[kept++] = _events[i]; + } + } + + _events.resize(kept); + _eventindices.clear(); + for (size_t i = 0; i < kept; ++i) _eventindices.emplace_back(i); + + return moved; +} } // namespace Clap::AUv2 diff --git a/src/detail/auv2/process.h b/src/detail/auv2/process.h index 30186b1e..004a7e11 100644 --- a/src/detail/auv2/process.h +++ b/src/detail/auv2/process.h @@ -113,6 +113,10 @@ class ProcessAdapter UInt32 inOffsetSampleFrame); void stopNote(int32_t note_id, int16_t channel, UInt32 inOffsetSampleFrame); void addParameterEvent(const clap_param_info_t &info, double value, uint32_t inOffsetSampleFrame); + // Hands the parameter events still queued here to another adapter, so they + // survive this one being destroyed. Returns how many moved. See + // WrapAsAUV2::deactivateCLAP(). + size_t transferPendingParametersTo(ProcessAdapter &other); // void startNote() ~ProcessAdapter(); diff --git a/src/wrapasauv2.cpp b/src/wrapasauv2.cpp index f94388e4..d0dac7a1 100644 --- a/src/wrapasauv2.cpp +++ b/src/wrapasauv2.cpp @@ -1570,7 +1570,6 @@ void WrapAsAUV2::deactivateCLAP() // pointer and then use it, and the idle tick may be inside a flush on it. ClapWrapper::detail::shared::SpinLockGuard processGuard(_processLock); _initialized = false; - _processAdapter.reset(); // Stand the deactivated-state adapter up here, on the main thread, rather // than leaving SetParameter to build it on demand. AUBase calls @@ -1580,7 +1579,31 @@ void WrapAsAUV2::deactivateCLAP() // rebuild its DSP. A render thread that had to allocate the replacement // would be allocating inside a real-time callback; finding one already // here makes it a queue push and nothing more. - ensureFlushAdapter(); + // + // It is also where the process adapter's own backlog goes. Whatever the + // host set since the last render or flush is still queued there, and + // dropping the adapter would drop it: the host was told the value took, + // and the plugin would never hear it. This is the same loss SetParameter + // avoids while the CLAP is deactivated, at the other end of the window -- + // the moment the adapter goes away rather than the span when there is + // none. Delivery is the deactivated path's job from here: the next idle + // tick, or activateCLAP() ahead of clap_plugin.activate(), whichever + // comes first, both on the main thread with the plugin inactive, which is + // where clap_plugin_params.flush() is legal. Nothing is handed to the + // plugin here -- it is still active until the deactivate() below, and a + // flush at this point would have to claim the audio thread during + // teardown. + if (auto *flushAdapter = ensureFlushAdapter()) + { + if (_processAdapter && _processAdapter->transferPendingParametersTo(*flushAdapter) > 0) + { + // Nothing else would ask: the transfer is not a SetParameter, and the + // idle tick only flushes when something has asked it to. + _requestedFlush = true; + } + } + + _processAdapter.reset(); } _plugin->stop_processing(); _plugin->deactivate();