Preview rides the real note path: mailbox drains into VoiceEngine noteOn/noteOff at the root note; PreviewCard retired; first-poll reopen heal reloads a silent-but-should-be-loaded instrument

This commit is contained in:
2026-07-28 10:30:51 -04:00
parent 6b85df8a8c
commit 81c4658711
5 changed files with 136 additions and 385 deletions
+34 -30
View File
@@ -35,11 +35,10 @@ namespace reasampler::vst {
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
// One fully-built, ready-to-play instrument snapshot: the decoded keymap, the voice
// engine that plays it, and the isolated PREVIEW CARD (Phase S) summed alongside it.
// Engine and card both hold references into the keymap, so the three MUST live and die
// together at a STABLE address — hence this is heap-allocated and neither copyable nor
// movable. The audio thread only ever reads it through an atomic pointer; it is built
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
// engine that plays it. The engine holds references into the keymap, so the two MUST live
// and die together at a STABLE address — hence this is heap-allocated and neither copyable
// nor movable. The audio thread only ever reads it through an atomic pointer; it is built
// and destroyed off the audio thread.
//
// installedAt: the reloadGeneration_ value at which this instrument was atomically
@@ -49,14 +48,13 @@ class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryI
struct LoadedInstrument {
Keymap keymap;
VoiceEngine engine;
PreviewCard preview; // Phase S: the isolated preview voice — never part of the pool
std::uint64_t installedAt = 0; // reload generation at which this was installed
// The takeover declick (GA fix, rev 2) is opted IN here — the PRODUCT default: any
// restart of a sounding voice (mono Retrigger takeover/fallback, cross-sample legato
// restart, POLY at-cap steal, AND the preview card's replace-restart) smooths the cut
// via the difference-seeded ramp instead of clicking. The pure core defaults it off
// (regression baseline) — same layering as the kDefaultPitchEngine product default.
// restart, POLY at-cap steal the preview note included, now that it is a real pool
// voice) smooths the cut via the difference-seeded ramp instead of clicking. The pure
// core defaults it off (regression baseline) — same layering as kDefaultPitchEngine.
LoadedInstrument(Keymap km, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0,
@@ -65,13 +63,12 @@ struct LoadedInstrument {
: keymap(std::move(km)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
preview(keymap, preserveWindowFrames, /*takeoverDeclick=*/true),
installedAt(gen) {}
// True when nothing in this snapshot is sounding — engine voices AND the preview card.
// process() publishes this for the drain slot so the off-thread retirer can park an
// idle drain in the graveyard early (FA1-review Major #2). Bounded scan (<= maxVoices).
bool fullyIdle() const { return engine.activeVoiceCount() == 0 && !preview.active(); }
// True when nothing in this snapshot is sounding. process() publishes this for the
// drain slot so the off-thread retirer can park an idle drain in the graveyard early
// (FA1-review Major #2). Bounded scan (<= maxVoices).
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
@@ -228,21 +225,24 @@ public:
}
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's PREVIEW CARD
// (S-VIEW-4; Phase S isolation) — a dedicated single voice structurally OUTSIDE the MIDI
// pool, so a full pool never drops a preview and a preview never steals a playing voice.
// OFF the audio thread (the editor's preview-trigger button drives these on the UI thread).
// The request is handed to process() via a lock-free single-slot mailbox drained at block
// start — no allocation, no lock on the audio thread. previewNoteOn plays `note` at the
// current previewVelocity(); previewNoteOff releases it (Gate) — Trigger zones ignore
// note-off and play through. A momentary button (down = on, up = off) reads as a natural
// key press. This is PLAYBACK ONLY: it never captures, never inserts a timeline item.
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's MAIN
// VoiceEngine — the SAME noteOn/noteOff calls host MIDI takes, so a preview is a REAL
// voice: it counts against the voice count, can steal / be stolen, and respects
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
// isolation — preview must obey voicing). The editor posts the loaded capture's /
// selected zone's ROOT note (plays at unity); previewNoteOn plays it at the current
// previewVelocity() (the velocity curve applies); previewNoteOff releases it (Gate) —
// Trigger zones ignore note-off and play through. OFF the audio thread (the editor's
// preview-trigger button, UI thread); the request is handed to process() via a
// lock-free single-slot mailbox drained at block start — no allocation, no lock on the
// audio thread. A momentary button (down = on, up = off) reads as a natural key press.
// This is PLAYBACK ONLY: it never captures, never inserts a timeline item.
void previewNoteOn(int note);
void previewNoteOff(int note);
private:
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
// CURRENT drain instrument is fully idle (every engine voice + the preview card silent),
// CURRENT drain instrument is fully idle (every engine voice silent),
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
// stops costing resident memory as soon as its tails die, instead of squatting in the slot
// until the NEXT reload. Off the audio thread only (takes reloadMutex_); driven from
@@ -254,8 +254,8 @@ private:
// proof (see below) covers the free.
void retireIdleDrain();
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine + preview
// card around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine
// around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
// full reloadFromBank (which re-decodes every zone WAV from disk on the UI thread) was
@@ -310,7 +310,7 @@ private:
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}; // min installedAt held by process (written on audio thread, read off-thread)
// Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE
// (0 = none / the current drain still sounds). Written relaxed on the audio thread each
// (every engine voice silent; 0 = none / the current drain still sounds). Written relaxed on the audio thread each
// block; read by retireIdleDrain() off-thread. Naming the generation (not a bool) closes
// the swap race: a publication about an old drain can never retire its successor.
std::atomic<std::uint64_t> drainIdleGeneration_{0};
@@ -352,7 +352,11 @@ private:
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
// FIRST poll after an editor open BASELINES the seen value without a redundant reload (setState
// already loaded the current bank); a subsequent generation CHANGE then drives the reload.
// NOT read on the audio thread.
// REOPEN HEAL exception: when that setState-time load LEFT NOTHING LIVE despite restored
// intent (a selection or zones) — the project-load ordering can run setState before the
// extension's PROJEXTSTATE block is parseable, so the bridge read came back empty — the
// first poll reloads instead of silently baselining, or the instrument would stay silent
// until some param change forced a reload. NOT read on the audio thread.
std::int64_t lastSeenBankGeneration_ = -1;
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
@@ -384,8 +388,8 @@ private:
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
// drains it at block start and drives the live instrument's PREVIEW CARD (Phase S — never the
// MIDI pool). ONE slot per direction, each a packed
// drains it at block start and drives the live instrument's MAIN VoiceEngine — the same
// noteOn/noteOff host MIDI takes, so the preview obeys voicing. ONE slot per direction, each a packed
// request whose high bits are a monotonically-incrementing sequence so process() detects a NEW
// request by comparing against the last sequence it consumed (never re-firing a stale one). The
// low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the