self-contained playback — ComponentState v10 SampleRefs (path+intrinsics owned by the instance), reloadInstrument decodes bank-free, heal timer + poll-to-play removed; bank is a browser source

This commit is contained in:
2026-07-28 11:45:10 -04:00
parent 93e28f4ea5
commit 261a6affa5
11 changed files with 697 additions and 349 deletions
+61 -61
View File
@@ -10,11 +10,19 @@
// state (the selected sample), and the IEditController seat so createView() can hand the
// host our IPlugView LICE editor.
//
// SELF-CONTAINED PLAYBACK (pS architecture correction). The instance OWNS its sample: the
// component state persists, per referenced bank sample, the project-relative WAV path +
// decode intrinsics (SampleRefs), and reloadInstrument decodes straight from that table.
// The extension's bank blob is a BROWSER SOURCE that opportunistically refreshes the refs
// when readable — NEVER a runtime requirement for playback. A project restored before the
// extension's PROJEXTSTATE parses (or with the extension absent) plays on load; the old
// reopen-heal timer + poll-to-play machinery that papered over the bank dependency is gone.
//
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state
// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens
// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to
// process via a single atomic pointer swap. See the LoadedInstrument handoff below.
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — ref resolve, WAV
// decode, keymap build, VoiceEngine construction — all happens OFF the audio thread
// (reloadInstrument, driven from the main/UI thread) and is handed to process via a
// single atomic pointer swap. See the LoadedInstrument handoff below.
#pragma once
@@ -131,27 +139,35 @@ public:
}
// Called by the editor (main/UI thread) when the user picks a sample, and internally
// on load. Reads the live bank over the bridge, resolves+decodes the selected WAV
// OFF the audio thread, and publishes the built instrument to process() via an
// atomic swap. Safe to call with no bridge / no bank (leaves silence). Returns the
// resolved selection id ("" if nothing was loaded) for the editor to reflect.
std::string reloadFromBank();
// on load. SELF-CONTAINED (pS): resolves the selection/zones against the instance-OWNED
// SampleRefs table, decodes each WAV OFF the audio thread, and publishes the built
// instrument to process() via an atomic swap — NO bank read is required for playback.
// When the live bank blob IS readable it is first folded into the refs table
// (refreshRefsFromBank), which is both the browser's copy-the-ref-in mechanism and the
// S9 live-recapture sync. A missing/unreadable WAV is the defined no-play (silence, no
// retry). Returns the resolved selection id ("" if nothing was loaded) for the editor.
std::string reloadInstrument();
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
// can react (repaint / re-snapshot its own view) only when something actually changed.
struct BankSyncResult {
bool reloaded = false; // the bank generation changed -> reloadFromBank ran
// The bank generation changed (or a pre-v10 legacy lift landed an instrument) ->
// reloadInstrument ran and the editor should re-snapshot its bank view.
bool reloaded = false;
bool applied = false; // a new assignment request was applied -> selection changed
};
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). Semantics:
// * S9: if the bank generation differs from what we last saw, call reloadFromBank() so a
// recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). This is an
// EDITOR/BROWSER sync path — playback never depends on it (pS). Semantics:
// * S9: if the bank generation differs from what we last saw, call reloadInstrument() so
// a recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
// a non-target instance neither applies nor advances its marker.
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
// bank read until the blob is parseable, then reloads ONCE to copy the refs in.
// The consumed marker advances in component state (marked dirty via the host handler) so a
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
// (the editor passes true only for the instance whose editor is open — see the handoff).
@@ -162,7 +178,7 @@ public:
// editor borrows it (outlives the editor).
ReaperBridge& bridge() { return bridge_; }
// The live host sample rate latched from setupProcessing (the SAME rate reloadFromBank
// The live host sample rate latched from setupProcessing (the SAME rate reloadInstrument
// resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place
// its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before
// setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_
@@ -177,14 +193,14 @@ public:
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
// audio thread — reloadFromBank bakes it into the LoadedInstrument's Keymap off-thread.
// audio thread — reloadInstrument bakes it into the LoadedInstrument's Keymap off-thread.
PerformanceMap performanceMap();
void setPerformanceMap(const PerformanceMap& map);
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
// (the editor toggle) and read off-thread by getState/reloadFromBank; guarded by
// (the editor toggle) and read off-thread by getState/reloadInstrument; guarded by
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
// negotiated output channel count, and reloadFromBank bakes the mode into the decode.
// negotiated output channel count, and reloadInstrument bakes the mode into the decode.
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
@@ -240,32 +256,12 @@ public:
void previewNoteOn(int note);
void previewNoteOff(int note);
// Reopen-heal retry tick — the target of the module-internal Win32 heal timer (see
// armHealRetry below), NOT host-facing. Public only because the file-static TIMERPROC
// in the .cpp must reach it. Main thread; re-runs reloadFromBank (which disarms the
// timer itself on success) and disarms when the bounded retry budget runs out.
void healTick();
// The instance-owned sample refs (pS self-contained playback): a snapshot copy for the
// editor (waveform/loop-intrinsic fallback when the bank blob is not readable). UI
// thread; guarded by refsMutex_.
SampleRefs sampleRefs();
private:
// --- Reopen-heal retry (the NON-editor reload trigger) --------------------------
// A project-restored instance with intent (a selection or zones) can come up SILENT:
// REAPER runs track-FX setState before the extension's PROJEXTSTATE block is parsed,
// so the setState-time reload reads an empty bank. The editor's WM_TIMER poll heals
// that — but only if the user opens the editor; an instance played via host MIDI with
// the editor never attached stayed silent indefinitely. Mechanism: reloadFromBank
// itself detects "built NOTHING despite restored intent, bridge connected" (off the
// audio thread — it just tried) and arms a BOUNDED, HWND-less Win32 retry timer
// (SetTimer + TIMERPROC: fires on the arming thread's message pump — REAPER's main
// thread, where every reload path already runs). Each tick re-runs reloadFromBank,
// which disarms on success or when the intent is gone; the bound stops the churn for
// an instance whose WAV is genuinely missing. A deliberately-empty instance never
// arms (no intent). process() is untouched — fully RT-safe. Main-thread only.
// No-ops on non-Windows builds (the VST target is Windows-only).
void armHealRetry();
void disarmHealRetry();
std::uintptr_t healTimerId_ = 0; // 0 = not armed (main thread only)
int healRetriesLeft_ = 0; // remaining timer-tick retries (main thread only)
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
// 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
@@ -283,7 +279,7 @@ private:
// 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
// full reloadInstrument (which re-decodes every zone WAV from disk on the UI thread) was
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
// it is immutable after construction and, under reloadMutex_, the live instrument can
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
@@ -293,7 +289,7 @@ private:
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
// process()-published generation, swap `built` into live_, displace the previous live into
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
// reloadMutex_ held — factored out so reloadFromBank and rebuildVoiceEngine share the ONE
// reloadMutex_ held — factored out so reloadInstrument and rebuildVoiceEngine share the ONE
// safety-critical swap dance (see the handoff proof below).
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
@@ -303,7 +299,7 @@ private:
// 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
// reloadInstrument() (off-thread, serialized by reloadMutex_) builds a new
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
// 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 —
@@ -349,16 +345,25 @@ private:
std::string selectedSampleId_;
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
// guarded against a getState/editor race. NOT read on the audio thread — reloadFromBank
// guarded against a getState/editor race. NOT read on the audio thread — reloadInstrument
// bakes it into the LoadedInstrument's Keymap under the reload lock.
std::mutex performanceMutex_;
PerformanceMap performanceMap_;
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadFromBank);
// The instance-OWNED sample refs (pS self-contained playback): the path + intrinsics
// per referenced bank sample that setState restores, reloadInstrument resolves/decodes
// from, and getState persists (v10). Refreshed opportunistically from the bank blob
// when it is readable; NEVER a bank dependency for playback. Off-thread only (UI +
// load/save + reload); guarded against a getState/reload race. NOT read on the audio
// thread.
std::mutex refsMutex_;
SampleRefs sampleRefs_;
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
// on the audio thread — process renders against the host's negotiated output channel count.
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
// reloadFromBank may auto-default from the loaded capture's channel count; true = the user
// reloadInstrument may auto-default from the loaded capture's channel count; true = the user
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
std::mutex channelModeMutex_;
ChannelMode channelMode_ = ChannelMode::Mono;
@@ -375,17 +380,12 @@ private:
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
// -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.
// 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. If that heal reload STILL leaves nothing live,
// pollBankSync resets this back to the -1 sentinel so the next tick re-arms the heal —
// the retry is bounded by the intent check (a deliberately-empty instance never heals),
// and it also covers a bank whose generation counter was never bumped (0 != 0 can never
// fire the generation path). NOT read on the audio thread.
// FIRST poll after an editor open BASELINES the seen value without a redundant reload
// (setState already loaded the instrument from the OWNED refs); a subsequent generation
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
// depends on this poll — a v10 blob plays from its own refs at setState time. The only
// poll-driven reload besides a generation change is the pre-v10 LEGACY LIFT (see
// pollBankSync). 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
@@ -397,9 +397,9 @@ private:
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
// Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread
// only (UI voice deck + getState/setState + reloadFromBank); guarded against a getState/editor
// only (UI voice deck + getState/setState + reloadInstrument); guarded against a getState/editor
// race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio
// thread — reloadFromBank bakes them into the LoadedInstrument's engine off-thread.
// thread — reloadInstrument bakes them into the LoadedInstrument's engine off-thread.
std::mutex voiceParamsMutex_;
int voiceCount_ = kDefaultVoiceCount;
VoiceMode voiceMode_ = VoiceMode::Poly;
@@ -435,7 +435,7 @@ private:
// Latched from setupProcessing so setActive/reload can size against it. Read
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
// before any audio, and reloadFromBank guards on it before use.
// before any audio, and reloadInstrument guards on it before use.
double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096;