// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the // pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio // output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the // engine's audio into the output bus — so a chosen bank sample plays chromatically from // its root note in REAPER's routing/record/render path. // // SingleComponentEffect is the SDK's combined processor+controller base — sanctioned // for a non-distributable, REAPER-only plugin under D5/D6. It gives us // addAudioOutput/addEventInput, IComponent setState/getState for the instance's own // state (the selected sample), and the IEditController seat so createView() can hand the // host our IPlugView LICE editor. // // 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. #pragma once #include #include #include #include #include #include #include "public.sdk/source/vst/vstsinglecomponenteffect.h" #include "reaper_bridge.h" #include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) #include "sampler_core.h" 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 // and destroyed off the audio thread. // // installedAt: the reloadGeneration_ value at which this instrument was atomically // installed into live_. Set on the reload path before the exchange. process() publishes // this field (not a fresh re-read of reloadGeneration_) so the published generation is // exactly the generation of the instrument actually in hand for the block. 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) 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) fades the cut tone over a few ms instead of clicking. The pure core // defaults it off (regression baseline) — same layering as the kDefaultPitchEngine // product default. LoadedInstrument(Keymap km, std::size_t maxVoices, std::uint64_t gen, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, VoiceMode voiceMode = VoiceMode::Poly, MonoTrigger monoTrigger = MonoTrigger::Retrigger) : keymap(std::move(km)), engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames, voiceMode, monoTrigger, /*takeoverDeclick=*/true), preview(keymap, preserveWindowFrames), 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(); } LoadedInstrument(const LoadedInstrument&) = delete; LoadedInstrument& operator=(const LoadedInstrument&) = delete; }; class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; // Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here) // is a complete type at the destruction point (defined in the .cpp). ~ReaSamplerProcessor() override; // The factory create function (registered in vst_entry.cpp). static Steinberg::FUnknown* createInstance(void* /*context*/); //--- from IComponent / IPluginBase ------------------------------------- // Connects the REAPER bridge (context is REAPER's IHostApplication) and declares // the instrument bus topology. Steinberg::tresult PLUGIN_API initialize(Steinberg::FUnknown* context) override; Steinberg::tresult PLUGIN_API terminate() override; Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override; // Instance state = the selected bank sample id (D-B: a performance choice the // instrument owns; NEVER written back to the bank). Component-state, so a saved // REAPER project restores which sample each instance plays. Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override; Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override; //--- from IAudioProcessor ---------------------------------------------- Steinberg::tresult PLUGIN_API setupProcessing( Steinberg::Vst::ProcessSetup& setup) override; // Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock). Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; // S7 channel-mode bus negotiation. The instrument has ONE canonical output arrangement // determined by its per-instance channel mode (mono -> kMono, stereo -> kStereo). We // accept the host's proposal only when it matches that arrangement; otherwise we reject // (kResultFalse) but keep the mode's arrangement, so getBusArrangement / getBusInfo always // report the mode's channel count and REAPER routes accordingly. A runtime mode change // updates the output bus + calls restartComponent(kIoChanged) to trigger re-negotiation. Steinberg::tresult PLUGIN_API setBusArrangements( Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns, Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override; //--- from IEditController ----------------------------------------------- // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; // Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6): // REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface. // All other iids delegate to SingleComponentEffect's implementation unchanged. Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override; // The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread. // Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a // lock-free advisory readout, never touched with a lock the audio thread could contend. double embedActivityLevel() const { return static_cast(embedPeak_.load(std::memory_order_relaxed)); } // 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(); // 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 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). // * 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. // 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). // Idempotent on an idle tick (generation unchanged + no new request -> no work). BankSyncResult pollBankSync(bool isFocusedTarget); // The bridge, for the editor's live-state readout + sample list. Owned here; the // editor borrows it (outlives the editor). ReaperBridge& bridge() { return bridge_; } // The live host sample rate latched from setupProcessing (the SAME rate reloadFromBank // 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_ // is set once by setupProcessing before any audio and does not change under the editor. double sampleRate() const { return sampleRate_; } // The current single-capture selection id (main/UI thread reads for the editor). Guarded // by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked // capture the default face plays chromatically when the performance map is empty; an EMPTY // id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it. std::string selectedSampleId(); void setSelectedSampleId(const std::string& id); // 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. 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 // 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. ChannelMode channelMode(); // Sets the mode. When it CHANGES, updates the output bus arrangement (mono->kMono / // stereo->kStereo) and asks the host to re-negotiate I/O via restartComponent(kIoChanged), // then reloads the instrument so the next block decodes the new channel count. A no-op set // (same mode) does neither. UI thread only. void setChannelMode(ChannelMode mode); // The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the // UI thread (the Sample-view velocity knob) and by getState/setState (host load-save thread); // guarded by previewMutex_. Persisted in component state (v6). NOT read on the audio thread. std::uint8_t previewVelocity(); void setPreviewVelocity(std::uint8_t velocity); // --- Phase S voice-system parameters (per-instance, persisted in component state v7) --- // Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded // by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine // OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no // bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap, // so changing polyphony / mode / the retrigger toggle never cuts a ringing tail. int voiceCount(); void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount VoiceMode voiceMode(); void setVoiceMode(VoiceMode mode); MonoTrigger monoTrigger(); void setMonoTrigger(MonoTrigger trigger); // --- FB1 post-mixer master gain (per-instance, persisted in component state v8) --------- // LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap = // +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the // audio thread applies it with ONE relaxed load per block as a post-sum multiply over the // rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost. // Written by the editor's Gain knob (UI thread) and setState; read by getState + process(). double masterGainLinear() const { return static_cast(masterGain_.load(std::memory_order_relaxed)); } 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. void previewNoteOn(int note); void previewNoteOff(int note); private: // Apply `mode` to the output audio bus's SpeakerArrangement (kMono / kStereo). Called from // initialize (topology) and setChannelMode (runtime change). Does NOT re-negotiate — the // caller drives restartComponent when appropriate. void applyOutputArrangement(ChannelMode mode); // 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), // 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 // pollBankSync's UI-timer tick (the same cadence that drives reloads — an idle drain with // no editor open simply waits for the next reload/deactivate, exactly the pre-fix bound). // Safe against a racing process(): idleness is monotone (the drain receives no note-ons) // and the published value names the drain's OWN installedAt, so a stale publication about // an OLDER drain can never retire a newer one; the graveyard prune's monotone-generation // 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 // 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 // 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 — // the new params bake into the next real reload. Off the audio thread only. void rebuildVoiceEngine(); // 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 // safety-critical swap dance (see the handoff proof below). void publishBuiltLocked(std::unique_ptr built); ReaperBridge bridge_; // --- 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 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 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: 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 0–1 in normal use). Remaining entries drain at setActive(false) / // terminate(), when the host guarantees process is stopped. std::atomic live_{nullptr}; std::atomic draining_{nullptr}; // displaced instrument still rendering its tails std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) std::atomic 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 // 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 drainIdleGeneration_{0}; std::vector> 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). // Off-thread only; a small mutex guards the string against a getState/editor race. NOT // read on the audio thread. std::mutex selectionMutex_; 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 // 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); // 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. std::mutex channelModeMutex_; ChannelMode channelMode_ = ChannelMode::Mono; // The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in // component state (v5) so a re-open does not re-apply a request the user already got and // then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by // pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER // read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies. std::mutex assignMarkerMutex_; std::int64_t lastConsumedAssignGeneration_ = 0; // 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. // 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 // user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view // velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and // getState (load/save thread) share the same guard. Default kPreviewVelocityDefault (64). NOT // read on the audio thread. std::mutex previewMutex_; 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 // 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. std::mutex voiceParamsMutex_; int voiceCount_ = kDefaultVoiceCount; VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; // FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free // atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward // it per-sample each block (linear interpolation, ~20 ms at 48 kHz / 256-frame block) // so sudden knob moves produce no zipper noise and the true-zero bottom causes no click. std::atomic masterGain_{1.0f}; // The audio-thread running gain value: tracks masterGain_ across blocks, stepping at // most kGainRampRate per sample toward the target. Starts at unity (pre-FB1 default). // Written and read exclusively on the audio thread — no atomics needed. float gainCurrent_ = 1.0f; // --- 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 // 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 // next 8 bits, latched at post time so the audio thread reads no shared velocity field. A single // relaxed atomic load per block on the audio thread — RT-safe (no alloc, no lock). // packed = (seq << 16) | (velocity << 8) | note [note-on] // packed = (seq << 16) | note [note-off] std::atomic previewOnRequest_{0}; // 0 = no request posted yet std::atomic previewOffRequest_{0}; std::uint16_t previewOnSeq_ = 0; // UI-thread post counter (never 0 after first post) std::uint16_t previewOffSeq_ = 0; std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired // 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. double sampleRate_ = 0.0; Steinberg::int32 maxBlockSize_ = 4096; // --- S6 embedded TCP/MCP UI --------------------------------------------- // The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface // and owned here for the processor's lifetime. REAPER borrows AddRef'd references from // queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its // destruction (the processor always outlives the borrowed references). std::unique_ptr embed_; // The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's // level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic, // no ordering coupling, never guarded by a lock the audio thread touches. std::atomic embedPeak_{0.f}; }; } // namespace reasampler::vst