#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 #include #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 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 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 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 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 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& 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 takeRestores() { std::vector taken; std::vector 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 pending_; }; // The FX half of a snapshot, with the keying it must be read back under. struct PreParkFx { std::vector 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& 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& 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 deferFxPark(ReaProject* proj, const std::string& guid); void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector 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