Add the FX reclaimer: a debounced, time-boxed convergence loop that offlines parked chains and restores returning ones off the switch path
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
#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 {
|
||||
|
||||
// --- 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 plugin and cannot
|
||||
// be split, so a budget decides only how many plugins a tick moves.
|
||||
//
|
||||
// ONLINE 120ms: onlining restores state the user owns and is waiting on, so a tick
|
||||
// buys at least one typical load and two cheap ones. OFFLINE 30ms: under one typical
|
||||
// unload, so opportunistic reclaim moves exactly ONE plugin per tick and yields --
|
||||
// background work must not make the UI feel worse than the switch already did. The
|
||||
// budget is checked BEFORE each step and never mid-step, so a plugin 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 — including its
|
||||
// resume cursor — 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(); }
|
||||
|
||||
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 resume cursor --------------------------------------------------------
|
||||
|
||||
// A Slot-keyed ONLINE record (lifted from a project saved before FX identity existed)
|
||||
// carries no identity in its targets, so an identity cursor would match nothing there
|
||||
// and restart the item on every yield. Every other shape matches on the live identity,
|
||||
// which is what survives a chain reorder between two ticks.
|
||||
inline bool resumeBySlotOnly(const view::FxTrackWork& work) {
|
||||
return work.kind == view::FxWorkKind::Online && !work.targets.empty() &&
|
||||
work.targets.front().keying == FxKeying::Slot;
|
||||
}
|
||||
|
||||
inline std::string slotIdentity(const std::vector<std::string>& liveFxGuids, int slot) {
|
||||
const auto i = static_cast<std::size_t>(slot);
|
||||
return slot >= 0 && i < liveFxGuids.size() ? liveFxGuids[i] : std::string{};
|
||||
}
|
||||
|
||||
// Records the step just completed. The SLOT is written unconditionally because an FX
|
||||
// REAPER reports no GUID for has nothing else to carry the cursor — see FxTrackWork's
|
||||
// resumeAfterSlot for what the resolve does with the pair.
|
||||
inline void markResumePoint(view::FxTrackWork& work, const std::vector<std::string>& liveFxGuids,
|
||||
int slot) {
|
||||
work.resumeAfterFxGuid = resumeBySlotOnly(work) ? std::string{} : slotIdentity(liveFxGuids, slot);
|
||||
work.resumeAfterSlot = slot;
|
||||
}
|
||||
|
||||
// --- the drain ----------------------------------------------------------------
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// One tick of the convergence loop. Templated on `Host` so the ordering, budget, resume
|
||||
// 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
|
||||
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;
|
||||
|
||||
std::vector<std::string> liveFxGuids;
|
||||
if (!host.liveChain(item.work.trackGuid, liveFxGuids)) {
|
||||
// Track gone. Nothing to write, and no record to release either — the next
|
||||
// apply's reconcile drops the whole record for a GUID that left the project.
|
||||
item.done = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
bool yielded = false;
|
||||
for (const FxOfflineWrite& w : resolved.writes) {
|
||||
if (host.nowMs() - start >= budget) { yielded = true; break; }
|
||||
if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes;
|
||||
markResumePoint(item.work, liveFxGuids, w.fxIndex);
|
||||
}
|
||||
if (yielded) return result; // the resume cursor picks this item up next tick
|
||||
|
||||
item.done = true;
|
||||
// The record's FX half is released HERE and only here: the chain has now been
|
||||
// driven to every state the record captured, so the record is spent. Releasing
|
||||
// it any earlier destroys the only account of which plugins the USER had
|
||||
// offline before the park, and the next online blanket-enables them.
|
||||
if (item.work.kind == view::FxWorkKind::Online) host.releaseFx(item.work.trackGuid);
|
||||
}
|
||||
|
||||
result.settled = queueSettled(items);
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
// Discards the derived queue. Called from every path that can change what the FX chains
|
||||
// SHOULD be — which is applyMode's success path and nothing else, because every
|
||||
// membership, snapshot and active-mode mutation reapplies through it. The debounce clock
|
||||
// deliberately survives: it is keyed on the track, not on the queue.
|
||||
void invalidateFxReclaim();
|
||||
|
||||
// Drains one tick against `proj` (nullptr = current project), re-deriving the queue from
|
||||
// `model` first if it was invalidated. Returns without touching the project while an
|
||||
// applyMode is on the stack, while already draining (TrackFX_SetOffline is assumed to
|
||||
// pump the message loop), or when the active project is not the one the queue was
|
||||
// derived against.
|
||||
void fxReclaimTick(ViewModeModel& model, ReaProject* proj);
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user