FA1: unity-Preserve bypass kills preview onset latency; drain-slot reload keeps voices ringing through curve edits; velocity path proven end-to-end

This commit is contained in:
2026-07-27 18:25:01 -04:00
parent 2b8ab4abe4
commit d9321eaf3a
6 changed files with 275 additions and 61 deletions
+25 -22
View File
@@ -204,39 +204,42 @@ private:
ReaperBridge bridge_;
// --- The audio-thread handoff (S4 real-time discipline) -----------------
// process() atomically loads `live_` at block start and marshals/renders against it —
// a single atomic acquire, no lock, no free on the audio thread.
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
// against them — two atomic acquires, no lock, no free on the audio thread.
//
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
// NOT freed on the reload path: process() may still be mid-block reading it, and two
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
// in `graveyard_` tagged with the reload generation at which it was displaced.
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
// a curve/param edit, a bank-generation refresh, an applied assignment — never cuts a
// ringing note (FA1, bug 3b). New note-ons go ONLY to the live instrument, so the next
// trigger plays the new state. The instrument evicted FROM the drain slot (two reloads
// old) is parked in `graveyard_` for reclaim — a rapid second reload hard-cuts only the
// oldest edit's tails (bounded compromise, documented).
//
// Bounded reclaim: process() publishes inst->installedAt (the generation at which the
// held instrument was installed) via processGeneration_ — a single atomic store, RT-
// safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen
// is the last published processGeneration_).
// Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null)
// pointers it holds this block via processGeneration_ — a single atomic store, RT-safe.
// The reload path frees graveyard entries whose installedAt < seen (the last published
// value).
//
// Safety argument: an entry with displacedAt == D was displaced by reload D, which
// simultaneously installed its successor with installedAt == D. process() publishing
// seen == D means it holds that successor (or a later one). In either case, the
// displaced entry is not the pointer process is using, so freeing it is safe. The
// pruning condition is therefore <= (not strict <): an entry displaced at exactly the
// published generation is also provably unreachable.
// Safety argument: both slots are monotone in installedAt over time (live_ receives
// successively newer builds; draining_ receives successively newer displaced lives), so
// the published minimum is monotone across blocks, and any future process() load yields
// installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots
// (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can
// never again be loaded and is not currently held — freeing it is safe. process()
// publishes BEFORE rendering, so the pointers it renders with are covered by the value
// the pruner reads (a stale lower read is merely conservative).
//
// The graveyard's upper bound is the number of reloads since process last ran
// (typically 01 in normal use). Remaining entries drain at setActive(false) /
// terminate(), when the host guarantees process is stopped.
std::atomic<LoadedInstrument*> live_{nullptr};
std::atomic<LoadedInstrument*> draining_{nullptr}; // displaced instrument still rendering its tails
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
std::atomic<std::uint64_t> processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread)
struct GraveyardEntry {
std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced
std::unique_ptr<LoadedInstrument> instrument;
};
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).