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