#pragma once // The FX reclaimer: the convergence loop that drives every parked chain offline and // every returning chain back to what its park record captured, off the mode switch's // synchronous path (TrackFX_SetOffline was 1897ms of a measured 1942ms switch). The // drain's decisions are pure and live here; the REAPER writes live in the .cpp. #include #include #include #include #include "core/view/fx_state.h" // Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. class ReaProject; namespace reasampler { class ReaSamplerSession; // --- tuning ------------------------------------------------------------------ // A track's OFFLINE work fires only after it has been continuously parked this long. // The consequence is wanted, not tolerated: a fast A->B->A flip moves zero plugins, // because the offline never fires and the online that follows finds every chain // already where the record wants it. inline constexpr double kParkHoldMs = 3000.0; // Per-tick budgets, set against the measured per-plugin costs (Phase Plant 36ms // offline / 105ms online; MTurboReverb 42ms / 240ms). A step is one whole track's // chain and cannot be split, so a budget decides only how many TRACKS a tick moves -- // and a single track with a long chain overruns it by as much as that chain costs. // // ONLINE 120ms: onlining restores state the user owns and is waiting on. The check // runs only AFTER a step, so against the cited 105ms typical load it admits TWO // typical tracks per tick (105 < 120, so a second runs; 210 >= 120 stops a third), // not one -- worst case a tick runs over budget by up to one track's whole cost. // OFFLINE 30ms: background work must not make the UI feel worse than the switch // already did. At the cited 36-42ms costs one track alone already clears the // budget, so opportunistic reclaim moves ONE track and yields -- but that count is a // property of those costs, not the budget: a cheap chain (a stock JS at ~1ms) stays // under 30ms for many tracks in a row and all of them move in one tick. Checked only // AFTER a step either way, never before the first and never mid-step, so a track // dearer than the whole budget still runs; that is what makes forward progress // unconditional. inline constexpr double kOnlineBudgetMs = 120.0; inline constexpr double kOfflineBudgetMs = 30.0; inline double tickBudgetMs(view::FxWorkKind kind) { return kind == view::FxWorkKind::Online ? kOnlineBudgetMs : kOfflineBudgetMs; } // --- the queue: a cache of intent, never state -------------------------------- // One derived work item plus this loop's own completion mark. The work is // core/view/fx_state's; nothing here is persisted, and the whole queue is discarded // and re-derived on any model change. struct ReclaimItem { view::FxTrackWork work; bool done = false; }; inline std::vector makeQueue(std::vector work) { std::vector items; items.reserve(work.size()); for (view::FxTrackWork& w : work) items.push_back(ReclaimItem{std::move(w), false}); return items; } inline bool queueSettled(const std::vector& items) { for (const ReclaimItem& it : items) if (!it.done) return false; return true; } // When each track owed OFFLINE work was first observed parked. Keyed on the track, not // on the queue, so it OUTLIVES a re-derivation: a membership edit somewhere else must // not restart an unrelated track's hold. An entry is dropped the moment its track stops // owing offline work, which is what makes a flip start a fresh hold rather than resume // the one it interrupted. class ParkClock { public: void observe(const std::vector& items, double nowMs) { std::map next; for (const ReclaimItem& it : items) { if (it.work.kind != view::FxWorkKind::Offline) continue; auto prior = since_.find(it.work.trackGuid); next[it.work.trackGuid] = prior != since_.end() ? prior->second : nowMs; } since_.swap(next); } // Absent answers false: observe() runs first every tick, so an unknown GUID is one // that owes no offline work at all. bool matured(const std::string& guid, double nowMs) const { auto it = since_.find(guid); return it != since_.end() && nowMs - it->second >= kParkHoldMs; } void forget() { since_.clear(); } // The owner swaps the clock out for the duration of a drain — see FxReclaimQueue. void swap(ParkClock& other) { since_.swap(other.since_); } private: std::map since_; }; // Online before offline, and an offline item only once its hold has matured. // desiredFxState already sorts Online first, so a plain scan carries the direction // priority; this adds only the debounce. inline int nextEligible(const std::vector& items, const ParkClock& clock, double nowMs) { for (std::size_t i = 0; i < items.size(); ++i) { if (items[i].done) continue; if (items[i].work.kind == view::FxWorkKind::Offline && !clock.matured(items[i].work.trackGuid, nowMs)) continue; return static_cast(i); } return -1; } // --- the drain ---------------------------------------------------------------- // One ONLINE item's captured state the chain had no home for. An OFFLINE item can // never produce one: its targets are the live chain's own slots, so every op resolves. struct TrackFxDrops { std::string trackGuid; FxRestoreDrops drops; bool operator==(const TrackFxDrops& o) const { return trackGuid == o.trackGuid && drops == o.drops; } }; // True when a drop set is worth printing: any change of owner or of the set itself. // The one-per-changed-set memo this gates is view_park's `shouldReport`/ // `reportExposedTracks`, which states the reason; the shell holds the memo. inline bool shouldReportDrops(bool sameOwnerAsLast, const std::vector& last, const std::vector& now) { return !(sameOwnerAsLast && last == now); } // What one tick did. `settled` means every item is complete and the caller may go idle // until the next invalidation. An item merely waiting out its debounce is NOT settled — // going idle there would strand its reclaim for the rest of the session. struct ReclaimTickResult { int writes = 0; bool settled = false; // Tracks whose park record the tick drove fully online, so the record's FX half is // spent. REPORTED, not applied: releasing is a model write and the model is exactly // what a re-entered load can replace mid-drain, so only the owner may commit it — // see FxReclaimQueue::run. std::vector released; // Per track that lost any, what the tick could not place. Carried out for the shell // to print through core/view/fx_offline's describeFxRestoreDrops — a plugin deleted // while its track was parked is a degrade the user has to repair by hand, and // swallowing it leaves no trace at all. std::vector drops; }; // One tick of the convergence loop. Templated on `Host` so the ordering, budget and // release bookkeeping are all exercised without REAPER (tests/test_fx_reclaim.cpp); // fx_reclaim.cpp holds the one live implementation. Host must provide: // // double nowMs() monotonic milliseconds // bool liveChain(guid, std::vector&) FX identity per live slot; // false when the track is gone // bool writeOffline(guid, fxIndex, offline) compare-before-write inside its // own discarded undo block; // true iff it actually wrote // void releaseFx(guid) release the record's FX half; // called by FxReclaimQueue::run // after the drain has unwound, // never from inside the drain // void persistModel() make the releases just applied // durable; run calls it ONCE per // tick and only after a release // // `items` and `clock` must be the caller's OWN storage, not shared state a re-entered // call could reach: a write pumps the message loop. FxReclaimQueue::run is what makes // that true for the live loop. template ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, Host& host) { ReclaimTickResult result; clock.observe(items, host.nowMs()); const int first = nextEligible(items, clock, host.nowMs()); if (first < 0) { result.settled = queueSettled(items); return result; } const view::FxWorkKind tickKind = items[static_cast(first)].work.kind; const double budget = tickBudgetMs(tickKind); const double start = host.nowMs(); for (;;) { const int i = nextEligible(items, clock, host.nowMs()); if (i < 0) break; ReclaimItem& item = items[static_cast(i)]; // Offline work never rides the online budget: the two directions are priced // apart deliberately, and a tick that started on one must not spend the other's. if (item.work.kind != tickKind) break; // A track gone from the project writes nothing and releases nothing — the next // apply's reconcile drops the whole record for a GUID that left it. std::vector liveFxGuids; if (host.liveChain(item.work.trackGuid, liveFxGuids)) { // Held in a named local: through C++20 a range-for does not extend the // lifetime of a temporary reached through a member access, so iterating the // resolution's `.writes` directly off the call would dangle. const FxRestoreResolution resolved = view::resolveFxWork(item.work, liveFxGuids); for (const FxOfflineWrite& w : resolved.writes) if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes; if (resolved.drops.total() > 0) result.drops.push_back(TrackFxDrops{item.work.trackGuid, resolved.drops}); // The record is spent once the chain has been driven to every captured state // that is still PLACEABLE — which is why the release fires with drops // outstanding: a dropped entry names an FX the chain no longer has, so // holding the record for it would replan work that can never resolve. // Releasing any EARLIER is what destroys the only account of which plugins // the USER had offline before the park, and the next online blanket-enables // them. Reported rather than applied; see ReclaimTickResult::released. if (item.work.kind == view::FxWorkKind::Online) result.released.push_back(item.work.trackGuid); } item.done = true; // both arms: a step is atomic, so no item is half-moved if (host.nowMs() - start >= budget) break; // between tracks only } result.settled = queueSettled(items); return result; } // --- the queue's owner ---------------------------------------------------------- struct ReclaimRunResult { int writes = 0; bool settled = false; // Something invalidated the queue while the drain was on the stack. The work and // the releases are DISCARDED, `settled` is never true, and the caller must not go // idle — the next tick re-derives against whatever changed. bool aborted = false; // Carried out on the abort path TOO, unlike the releases: a drop is a fact about the // chain the tick just wrote, not a claim about the model a re-entered load may have // replaced. The re-derivation that follows an abort resolves the very same set, // which is what shouldReportDrops is there to swallow. std::vector drops; }; // Owns the derived queue and the debounce clock across a tick that can be re-entered: // TrackFX_SetOffline is ASSUMED to pump the message loop, so an invalidation can arrive // with a drain half-way through a track. // // The two halves of that safety are structural, not guarded. `invalidate()` bumps a // counter and touches NOTHING else, so there is nothing for it to clear under a drain. // `run()` moves the queue and the clock into locals for the drain's whole duration, so // the object holds nothing the drain is reading — and re-checks the counter before it // commits either the surviving queue or a single release. class FxReclaimQueue { public: void invalidate() { ++generation_; } // No queue at all, or one derived before the last invalidation. bool stale() const { return !derived_ || derivedAt_ != generation_; } void adopt(std::vector items) { items_ = std::move(items); derived_ = true; derivedAt_ = generation_; } void discard() { items_.clear(); derived_ = false; } void forgetHolds() { clock_.forget(); } const std::vector& items() const { return items_; } template ReclaimRunResult run(Host& host) { const unsigned long long entered = generation_; std::vector work; ParkClock clock; work.swap(items_); clock.swap(clock_); const ReclaimTickResult tick = drainTick(work, clock, host); // The hold is keyed on the track and outlives any re-derivation, so it comes // back whether or not the drain's own work survives. clock_.swap(clock); if (generation_ != entered) return ReclaimRunResult{tick.writes, false, true, tick.drops}; items_.swap(work); // A release dropped by an abort is re-owed, not lost: the next derivation plans // the same Online item, its writes all find the chain already there, and the // release fires then. if (!tick.released.empty()) { for (const std::string& guid : tick.released) host.releaseFx(guid); // The spend has to be as durable as the record it spends. A reload — a // reopen, or the undo/redo loadFromProject — rebuilds the model from the // PERSISTED view state, and a record still held there re-derives this same // Online work and re-drives the chain, silently reverting whatever per-FX // offline the user set once the reclaim had finished. One persist per tick, // after every release the tick earned, and still killable in between: die // there and the record simply survives on disk, whereupon the re-derived // item finds the chain already online, writes nothing, and releases again. host.persistModel(); } return ReclaimRunResult{tick.writes, tick.settled, false, tick.drops}; } private: std::vector items_; ParkClock clock_; unsigned long long generation_ = 0; unsigned long long derivedAt_ = 0; bool derived_ = false; }; // --- shell entry points -------------------------------------------------------- // The ONE test the timer makes per tick, mirroring the realtime capture's single // pointer test. False means the queue is settled and there is nothing to converge. extern bool g_fxReclaimPending; // Marks the derived queue for re-derivation by the NEXT tick; discards nothing here, // because this is reachable from inside a drain. Called from applyMode and nothing else // — every membership, snapshot and active-mode mutation reapplies through it — on the // refusal path as well as the success one (see view.cpp for why the refusal is the // load-bearing half). The debounce clock deliberately survives: it is keyed on the // track, not on the queue. void invalidateFxReclaim(); // True while a drain is on the stack. Exposed so the timer's other work can stand off // the model the drain is holding, the same way it stands off an applyMode. bool fxReclaimDrainInProgress(); // Drains one tick against `proj` (nullptr = current project), re-deriving the queue from // the session's view model first if it was invalidated. Returns without touching the // project while an applyMode is on the stack, while already draining, or when the active // project is not the one the queue was derived against. Takes the SESSION rather than the // model alone because completing an online spends the park record's FX half, and that // spend has to reach the persisted view state — see FxReclaimQueue::run. void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); } // namespace reasampler