362 lines
17 KiB
C++
362 lines
17 KiB
C++
#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 <cstddef>
|
|
#include <map>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#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<ReclaimItem> makeQueue(std::vector<view::FxTrackWork> work) {
|
|
std::vector<ReclaimItem> 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<ReclaimItem>& 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<ReclaimItem>& items, double nowMs) {
|
|
std::map<std::string, double> 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<std::string, double> 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<ReclaimItem>& 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<int>(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<TrackFxDrops>& last,
|
|
const std::vector<TrackFxDrops>& 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<std::string> 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<TrackFxDrops> 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<std::string>&) 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 <class Host>
|
|
ReclaimTickResult drainTick(std::vector<ReclaimItem>& 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<std::size_t>(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<std::size_t>(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<std::string> 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<TrackFxDrops> 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<ReclaimItem> items) {
|
|
items_ = std::move(items);
|
|
derived_ = true;
|
|
derivedAt_ = generation_;
|
|
}
|
|
|
|
void discard() {
|
|
items_.clear();
|
|
derived_ = false;
|
|
}
|
|
|
|
void forgetHolds() { clock_.forget(); }
|
|
|
|
const std::vector<ReclaimItem>& items() const { return items_; }
|
|
|
|
template <class Host>
|
|
ReclaimRunResult run(Host& host) {
|
|
const unsigned long long entered = generation_;
|
|
|
|
std::vector<ReclaimItem> 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<ReclaimItem> 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
|