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:
2026-08-06 19:07:17 -04:00
parent a9d2397586
commit 5a35b71dc0
8 changed files with 840 additions and 22 deletions
+163
View File
@@ -0,0 +1,163 @@
// See fx_reclaim.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
// The drain's decisions are pure (header, test_fx_reclaim.cpp); this file owns the
// queue's lifetime, the per-step track resolve, and the writes.
#include "shell/view/fx_reclaim.h"
#include <chrono>
#include <set>
#include <string>
#include <vector>
#include "shell/view/view.h" // modeApplyInProgress
#include "shell/view/view_park.h" // fxGuidString — the one FX-identity read
#include "shell/view/view_tracks.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_ValidatePtr2
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// The derived queue and the project it was derived against. The pointer is
// COMPARE-ONLY and never dereferenced beyond the ValidatePtr2 the tick already makes —
// a tab switch has to be detectable without following a pointer that may name a closed
// project.
std::vector<ReclaimItem> g_items;
ReaProject* g_project = nullptr;
ParkClock g_clock;
// TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter
// the timer and land back here mid-drain. Fail closed: a re-entered drain would walk the
// same items the outer one is mutating and double-write from a stale resume cursor.
bool g_draining = false;
struct DrainLatch {
DrainLatch() { g_draining = true; }
~DrainLatch() { g_draining = false; }
DrainLatch(const DrainLatch&) = delete;
DrainLatch& operator=(const DrainLatch&) = delete;
};
// Resolved fresh from the GUID on every step rather than held across a write, for the
// same pump premise: a plugin load can run arbitrary REAPER code, and a handle taken
// before it is not one this code may trust after it.
MediaTrack* resolveFresh(ReaProject* proj, const std::string& guid) {
TrackHandles handles;
readFolderEntries(proj, handles);
for (const auto& kv : handles)
if (kv.first == guid) return kv.second;
return nullptr; // deleted, or moved to another project
}
std::set<std::string> liveTrackGuids(ReaProject* proj) {
TrackHandles handles;
readFolderEntries(proj, handles);
std::set<std::string> guids;
for (const auto& kv : handles) guids.insert(kv.first);
return guids;
}
// The live half of drainTick's Host contract (see fx_reclaim.h).
struct ReclaimHost {
ReaProject* project = nullptr;
ViewModeModel* model = nullptr;
// Monotonic and sub-millisecond: the budget below discriminates at 30ms, which
// GetTickCount's ~16ms granularity cannot do. No wraparound to guard against, so
// util::elapsedAtLeast (whose whole reason for existing is that wrap) does not apply.
double nowMs() const {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
MediaTrack* tr = resolveFresh(project, guid);
if (!tr) return false;
const int count = TrackFX_GetCount(tr);
out.clear();
out.reserve(static_cast<std::size_t>(count < 0 ? 0 : count));
// The plain 0..count-1 enumeration, matching the park snapshot's — never the
// 0x1000000 input-FX or 0x2000000 container forms.
for (int fx = 0; fx < count; ++fx) out.push_back(fxGuidString(tr, fx));
return true;
}
bool writeOffline(const std::string& guid, int fxIndex, bool offline) {
MediaTrack* tr = resolveFresh(project, guid);
if (!tr) return false;
if (fxIndex < 0 || fxIndex >= TrackFX_GetCount(tr)) return false; // chain shrank
if (TrackFX_GetOffline(tr, fxIndex) == offline) return false; // already there
// DAW-VERIFIED: the same ten TrackFX_SetOffline calls mint ten undo points
// unblocked and ZERO inside this pair. One block per write, not per tick: the
// write itself is what pumps, so the block cannot avoid spanning a pump — it can
// only span as little of one as possible.
Undo_BeginBlock2(project);
TrackFX_SetOffline(tr, fxIndex, offline);
Undo_EndBlock2(project, "", 0);
return true;
}
void releaseFx(const std::string& guid) { model->releaseSnapshotFx(guid); }
};
// Every path that stops scheduling ticks leaves the same state behind: no queue, no pin,
// nothing pending. The debounce clock is NOT part of it — see fx_reclaim.h.
void goIdle() {
g_items.clear();
g_project = nullptr;
g_fxReclaimPending = false;
}
} // namespace
bool g_fxReclaimPending = false;
void invalidateFxReclaim() {
goIdle();
g_fxReclaimPending = true;
}
void fxReclaimTick(ViewModeModel& model, ReaProject* proj) {
if (g_draining) return;
// The model is mid-rewrite and its records are being consumed out from under any
// plan derived from it. The apply invalidates on its way out, so nothing is lost.
if (modeApplyInProgress()) return;
DrainLatch latch;
// Both arms below are "the project this queue belongs to is no longer the one in
// front of us": no active project at all, or a tab switch / close. Drop the queue
// rather than write another project's chains; returning to it re-derives, because
// poll() reads that as a project load and the load glue reapplies, which invalidates.
// The clock goes too — a hold measured in a project we stopped watching is not
// evidence about the one we come back to.
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*") || (g_project && g_project != project)) {
goIdle();
g_clock.forget();
return;
}
// DERIVED, never persisted: recomputed from view state every time the queue was
// discarded, so a REAPER quit mid-drain simply re-derives and finishes on reopen.
if (!g_project) {
g_project = project;
g_items = makeQueue(
view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project)));
}
ReclaimHost host{project, &model};
if (drainTick(g_items, g_clock, host).settled) goIdle();
}
} // namespace reasampler
+233
View File
@@ -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
+7
View File
@@ -13,6 +13,7 @@
#include "core/view/solo_cache.h"
#include "core/view/view_tree.h"
#include "shell/view/fx_reclaim.h"
#include "shell/view/view_lanes.h"
#include "shell/view/view_park.h"
#include "shell/view/view_reconcile.h"
@@ -201,6 +202,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// The tool's own account of what it just left driven — the only one that survives
// an undo rolling the model back underneath it.
noteAppliedState(project, model);
// Every membership, snapshot and active-mode mutation in the system reaches the
// project through this call, so this is the whole invalidation surface: what the FX
// chains SHOULD be has just changed, and the reclaimer's queue is a cache of that
// intent, never state.
invalidateFxReclaim();
return true;
}
+19 -22
View File
@@ -22,28 +22,6 @@ namespace reasampler {
namespace {
// The FX's own durable identity, braced exactly like the track GUID keys. Empty
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
// settled: the string copy is taken immediately and the GUID* is never held past
// this call (reaper_plugin_functions.h:7348 documents no null contract for
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
//
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS move
// (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not follow
// the plugin after that call, i.e. wrong-plugin restores or mass drops through
// fx_offline on that path specifically), a save/reload round trip, and two live
// instances of one plugin type staying distinguishable. See src/shell/view/CLAUDE.md's
// Gotchas for the SWS-path risk this leaves open.
std::string fxGuidString(MediaTrack* tr, int fx) {
GUID* g = TrackFX_GetFXGUID(tr, fx);
if (!g) return {};
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
guidToString(g, buf);
return std::string(buf);
}
// Captures prior driven-flag state, and the chain's per-FX offline state, before
// parking. Never reads B_MUTE/I_SOLO; ints preserve whatever REAPER reported
// (TrackSnapshot's defensive contract). The plain 0..TrackFX_GetCount-1 enumeration
@@ -96,6 +74,25 @@ std::vector<std::string> g_reportedExposed;
const int kViewUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG;
// Lifetime is settled: the string copy is taken immediately and the GUID* is never held
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
//
// [verify — DAW] STABILITY across a chain mutation is not settled the same way: confirm
// the GUID for one FX instance survives a native drag-reorder, an SWS move
// (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not follow the
// plugin after that call, i.e. wrong-plugin restores or mass drops through fx_offline on
// that path specifically), a save/reload round trip, and two live instances of one plugin
// type staying distinguishable. See src/shell/view/CLAUDE.md's Gotchas for the SWS-path
// risk this leaves open.
std::string fxGuidString(MediaTrack* tr, int fx) {
GUID* g = TrackFX_GetFXGUID(tr, fx);
if (!g) return {};
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
guidToString(g, buf);
return std::string(buf);
}
bool writeIfChanged(MediaTrack* tr, const char* parm, double value) {
if (GetMediaTrackInfo_Value(tr, parm) == value) return false;
SetMediaTrackInfo_Value(tr, parm, value);
+6
View File
@@ -29,6 +29,12 @@ inline const char* trackFlagParm(Flag f) {
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
}
// The FX's own durable identity, braced exactly like the track GUID keys, empty when
// REAPER reports none. The ONE such read: the park's snapshot and the reclaimer's
// resolve must address the same chain by the same names, or a restore lands one
// plugin's state on another. See its definition for the stability caveat.
std::string fxGuidString(MediaTrack* tr, int fx);
// A re-park's FX half, unioned rather than overwritten: keeps every FX identity
// `prior` already names (the user's real pre-park state, still offline while the
// reclaimer hasn't run) and appends whatever identity `live` sees that `prior`