761125d0fe
Extracts parkReadyAt/parkIsReady as a tested pure fold per PLAN.md's phase criterion; the rest is wording fixes — hitch bound, hazard width, forced-drain scope, progressive CPU reclaim.
241 lines
12 KiB
C++
241 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 coalescing delay's gate, pure so the debounce can be asserted without a
|
||
// DAW clock (test_view_fx_park.cpp). parkReadyAt is called on EVERY enqueue,
|
||
// not just the first — that re-arming from each call's own `now` is what makes
|
||
// it a debounce rather than a one-shot timer, and is what lets a rapid A→B→A
|
||
// flip (three enqueues inside one delay window) cost a single wait measured
|
||
// from the last flip rather than three, or one anchored to the first.
|
||
inline double parkReadyAt(double now, double coalesceSeconds) { return now + coalesceSeconds; }
|
||
inline bool parkIsReady(double now, double readyAt) { return now >= readyAt; }
|
||
|
||
// 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;
|
||
// Set once a park write has landed on this track — the live chain then
|
||
// matches NEITHER endpoint, so the cancel-on-inverse rule below is unsafe
|
||
// until the intent resumes/supersedes instead of annihilating. Restores
|
||
// carry no such state: they are detached whole and applied in one drain.
|
||
// Full contract: src/shell/view/CLAUDE.md's `view_fx_park` entry.
|
||
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 and the cancel means
|
||
// no drain will ever put them back. Empty (nothing cancelled) means the caller
|
||
// reads the live chain instead; the restore's own keying travels with it so a
|
||
// slot-keyed snapshot does not silently become identity-keyed with no
|
||
// identities. The ops describe the chain as of the ORIGINAL park, not the live
|
||
// chain applyPark re-enumerates at drain time — so an FX added while parked (a
|
||
// floating FX-chain window, ReaScript) is absent here yet still offlined by the
|
||
// cancelling park's drain, and never comes back online. Rare and hand-recoverable.
|
||
// `offline` widens back from FxOfflineOp's bool to FxOfflineState's defensive int
|
||
// — restoring the type, not adding 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 forced by
|
||
// a subset of the paths that serialize the view model. WHICH paths force it, and
|
||
// why forcing is required, is src/shell/view/CLAUDE.md's Documented-caveat entry
|
||
// — kept there only, not restated here, so the two cannot drift apart.
|
||
void drainDeferredFxRestores();
|
||
|
||
// PARKS, one FX per idle tick behind a short coalescing delay (parkReadyAt /
|
||
// parkIsReady above). Unlike a restore it is safe to persist over — it
|
||
// converges on its own; see CLAUDE.md for why, and for the CPU-reclaim-is-
|
||
// progressive consequence.
|
||
//
|
||
// 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 — only the
|
||
// per-FX split does, and only to ONE FX's unload per tick, not to zero.
|
||
//
|
||
// The live-enumeration hazard's width is delay + (FX count × tick interval), NOT
|
||
// the delay alone: firstOnlineFx re-enumerates the chain LIVE every tick, not
|
||
// just the first, so an FX added at any point before the park retires is
|
||
// offlined carrying no snapshot entry — resolveFxRestore then has no op for it
|
||
// and it stays offline. Same failure as the cancelled-restore case above, over a
|
||
// longer window. Do not lengthen the delay casually; it is only one term.
|
||
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
|