Files
reasampler/src/shell/view/view_fx_park.h
T
daniel 6e2128e937 view: split the deferred FX-park drain by kind — restores stay forced, parks go one FX per idle tick behind a 1s coalescing delay
A rapid A→B→A flip now costs no plugin work: the restore cancels the still-pending
park outright. A park that has already written one FX carries a `partial` flag and is
superseded by its inverse rather than cancelled, so a half-parked chain is never stranded.
2026-08-03 15:00:40 -04:00

247 lines
12 KiB
C++

#pragma once
// Design View's per-FX offline surface: the FX-identity read that snapshot,
// park and restore all address FX through, the deferred intent queue that keeps
// TrackFX_SetOffline off the mode switch's synchronous path, and the two drains
// that apply it — restores whole, parks one FX per idle tick. See
// src/shell/view/CLAUDE.md's FX-parking caveat.
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
class MediaTrack;
class ReaProject;
namespace reasampler {
// The chain as it stands now: identity by current slot. Snapshot, park and
// restore all address FX through this one plain 0..TrackFX_GetCount-1
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
// whatever it covers, all three cover identically.
std::vector<std::string> liveFxGuids(MediaTrack* tr);
// One deferred per-FX intent for one track. A park carries no ops (every live
// slot goes offline); a restore carries the planned ops verbatim.
struct FxParkIntent {
std::string guid;
bool park = false;
// A park write is landing or has landed on this track, so the live chain
// matches NEITHER endpoint. Parks apply one FX per idle tick, so this is a
// state a park genuinely sits in — and while it holds, the cancel rule
// below is unsafe in both directions: only the restore's own ops can put
// back what a half-finished park offlined, and a park arriving on that
// restore must RESUME rather than annihilate or it strands every FX the
// first pass had not reached. Restores carry no such state: they are
// detached whole and applied in one drain.
bool partial = false;
std::vector<FxOfflineOp> restoreOps;
};
// The queue's re-entrancy rule, pure so it can be asserted without a DAW: at
// most ONE intent per track GUID, and the latest one wins. Park and restore are
// inverses, so an UNSTARTED intent landing on its own pending inverse CANCELS it
// rather than stacking — the queued work never ran, so the track already holds
// the state the newcomer asks for, and replaying both would be both slower and
// observably wrong. That cancel is what makes a rapid A→B→A mode flip cost zero
// plugin work: B→A's restore annihilates A's park before a single FX moved.
class FxParkQueue {
public:
// Returns the ops of a pending restore this park CANCELLED, empty otherwise.
// The caller needs them: that restore never ran, so the live chain still
// reads the PARKED offline states and is no longer a source for a fresh
// pre-park snapshot (see preParkFxFromCancelledRestore).
//
// The empty return is a LOAD-BEARING sentinel that deliberately conflates
// "nothing was cancelled" with "cancelled a restore carrying no ops": a
// zero-op restore held no FX state to hand back, so falling through to a
// live chain read is the same answer, not a worse one.
std::vector<FxOfflineOp> park(const std::string& guid) {
FxParkIntent* held = find(guid);
if (!held) {
pending_.push_back(FxParkIntent{guid, true, false, {}});
return {};
}
if (held->park) return {};
std::vector<FxOfflineOp> cancelled = std::move(held->restoreOps);
if (held->partial) *held = FxParkIntent{guid, true, true, {}};
else erase(held);
return cancelled;
}
// A restore cancelling an UNSTARTED park is COMPLETE at that point — the
// park never ran, so no drain will ever come for this GUID. Against a park
// that has already written, the restore supersedes instead: its ops are the
// only thing that can put the offlined FX back.
void restore(const std::string& guid, std::vector<FxOfflineOp> ops) {
FxParkIntent* held = find(guid);
if (!held) {
pending_.push_back(FxParkIntent{guid, false, false, std::move(ops)});
} else if (held->park && !held->partial) {
erase(held);
} else if (held->park) {
*held = FxParkIntent{guid, false, true, std::move(ops)};
} else {
held->restoreOps = std::move(ops);
}
}
// Enqueue order, which is apply order WITHIN a kind — the two kinds drain
// separately and on different schedules (see the two drains below).
const std::vector<FxParkIntent>& pending() const { return pending_; }
bool empty() const { return pending_.empty(); }
void clear() { pending_.clear(); }
// Detaches every pending RESTORE and leaves the parks behind, in order. The
// detach — not a live iteration, not a clear afterwards — is what lets the
// queue accept intents enqueued WHILE the caller applies what it took:
// [verify — DAW] applying loads/unloads plugins, which is ASSUMED to pump
// the message loop, so a re-entrant switch can enqueue mid-apply. Iterating
// the live queue would dangle on the push_back and clearing it afterwards
// would discard whatever arrived. Correct either way; only the need for it
// is unconfirmed.
std::vector<FxParkIntent> takeRestores() {
std::vector<FxParkIntent> taken;
std::vector<FxParkIntent> kept;
for (FxParkIntent& i : pending_) {
if (i.park) kept.push_back(std::move(i));
else taken.push_back(std::move(i));
}
pending_.swap(kept);
return taken;
}
// The park the drain should work next, in enqueue order; empty when none is
// pending. A PEEK, by value — an apply can reshape the queue under a pointer
// — and it marks nothing: a park still cancellable for free stays that way
// until markPartial says a write is imminent.
std::string nextParkGuid() const {
for (const FxParkIntent& i : pending_)
if (i.park) return i.guid;
return {};
}
// Records that a park write is ABOUT TO land on `guid` — set before the
// write, because the write may pump the message loop and an intent arriving
// in that window must already see a chain that matches neither endpoint.
void markPartial(const std::string& guid) {
if (FxParkIntent* held = find(guid)) held->partial = true;
}
// Retires a park with nothing left to offline. A no-op when a restore
// replaced it mid-apply — that restore is the newer intent and owns the
// chain from here.
void finishPark(const std::string& guid) {
FxParkIntent* held = find(guid);
if (held && held->park) erase(held);
}
private:
FxParkIntent* find(const std::string& guid) {
for (FxParkIntent& i : pending_)
if (i.guid == guid) return &i;
return nullptr;
}
void erase(FxParkIntent* held) {
pending_.erase(pending_.begin() + (held - pending_.data()));
}
std::vector<FxParkIntent> pending_;
};
// The FX half of a snapshot, with the keying it must be read back under.
struct PreParkFx {
std::vector<FxOfflineState> states;
FxKeying keying = FxKeying::Identity;
};
// The FX half a fresh pre-park snapshot must carry when the park CANCELLED a
// pending restore: those ops are the only surviving record of the pre-park
// state, because the chain still reads the parked values until that restore
// drains — and it never will, the cancel dropped it. Empty in (nothing was
// cancelled) means the caller reads the live chain instead. The restore's own
// keying travels with it so a slot-keyed snapshot lifted from a legacy
// view_state does not silently become an identity-keyed one with no identities.
//
// FAITHFUL TO THE SNAPSHOT, NOT THE CHAIN. The ops describe the chain as it was
// at the ORIGINAL park; applyPark enumerates it again at drain time. So an FX
// added while the track was parked (a floating FX-chain window, ReaScript) is
// absent from this reconstruction yet IS offlined by the cancelling park's
// drain — and so never comes back online. Rare, and recoverable by hand in the
// FX chain, but specific to the deferral.
//
// `offline` is already boolean by the time it arrives: makeRestorePlan narrowed
// FxOfflineState's defensive int to FxOfflineOp's bool, so this widening back to
// int restores the type, not lost information.
inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) {
PreParkFx out;
if (cancelled.empty()) return out;
out.keying = cancelled.front().keying;
out.states.reserve(cancelled.size());
for (const FxOfflineOp& op : cancelled)
out.states.push_back(FxOfflineState{op.fxGuid, op.offline ? 1 : 0});
return out;
}
// The FX half of a fresh pre-park snapshot for `tr`: whatever the accompanying
// park cancelled, else a live read of the chain.
PreParkFx snapshotFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelled);
// Enqueue against `proj` (nullptr = current project). An enqueue naming a
// different project than the pending intents discards those unapplied. The park
// returns whatever pending restore it cancelled, per FxParkQueue::park.
std::vector<FxOfflineOp> deferFxPark(ReaProject* proj, const std::string& guid);
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops);
// The two halves of the drain, split because only ONE of them is unsafe to
// persist over. Both touch NO model state — a restore's snapshot is dropped
// where the restore is planned (applyMode) — both discard the queue unapplied if
// the project it was enqueued against is no longer current (close / switch),
// both prune an intent whose track is gone, both cost one empty-queue test when
// idle, and both early-out re-entrantly: one drain at a time, the outer one owns
// the queue.
//
// RESTORES, applied whole, synchronously. Called on the idle tick AND before the
// view model is serialized (persistViewState, render_in_place). The second call
// is not an optimization: between a restore's synchronous flag writes and its
// drain the FX are still offline while the model has already dropped the
// snapshot that would replan them, so a save inside that window — deterministic
// under a custom action chain like "toggle mode; save project" — records offline
// FX beside a snapshot-free model, and nothing on reopen brings them back
// online. The cost is that an action-driven switch pays the RELOAD hitch before
// it returns; its repaint and undo block have both closed by then, which is what
// the deferral was for.
void drainDeferredFxRestores();
// PARKS, one FX per idle tick behind a short coalescing delay. Nothing waits on
// a park, and unlike a restore it is safe to persist over: the flags are already
// parked and the snapshot already stored, so a .rpp saved mid-window reopens with
// the FX online, applyMode replans a park for that inactive leaf, and the drain
// offlines it. It converges on its own, which is why this half does not force.
//
// One FX, not one track: a single convolution reverb or loaded sampler is the
// unit of cost, so per-track chunking would not bound the hitch.
//
// The delay's cost is the width of a documented hazard, not a new one. applyPark
// enumerates the chain LIVE at drain time while the pre-park snapshot was taken
// at switch time, so an FX added inside the window is offlined carrying no
// snapshot entry — resolveFxRestore then has no op for it and it stays offline.
// Same failure as the cancelled-restore case below, over a longer window; keep
// the delay short.
void tickDeferredFxParks();
// Drops every pending intent without applying it. Called when the model the
// intents were planned against has been replaced (project load/switch, undo/redo
// state restore) — applying them then would write the pre-reload plan over the
// project that replaced it.
//
// Not pure loss: the caller reapplies the active mode over the reloaded model
// immediately after, which re-plans a park for every inactive leaf, so parked FX
// converge on the following drain. The case that does NOT self-heal is a track
// whose reloaded model carries no snapshot — nothing plans a restore for it, so
// FX left offline stay offline.
void discardDeferredFxParks();
} // namespace reasampler