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/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/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) diff --git a/src/wrapasauv2.cpp b/src/wrapasauv2.cpp index 63f3b8a2..d0dac7a1 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(); } @@ -1533,6 +1570,39 @@ 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; + + // 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. + // + // 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(); @@ -1866,7 +1936,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 +1992,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 +2004,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 +2034,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)