Files
reasampler/src/shell/view/view_fx_park.h
T
daniel 761125d0fe view: assert the FX-park coalescing delay's debounce; correct four overclaiming doc/comment claims
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.
2026-08-03 15:50:07 -04:00

241 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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