// 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 and the voice // engine that plays it. The engine holds a reference 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 // 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; std::uint64_t installedAt = 0; // reload generation at which this was installed LoadedInstrument(Keymap km, std::size_t maxVoices, std::uint64_t gen, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0) : keymap(std::move(km)), engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames), installedAt(gen) {} 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); // Fire a one-shot PREVIEW note-on / note-off through the live voice engine (S-VIEW-4), 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); 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. // // 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. // // 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_). // // 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. // // 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 reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) std::atomic 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 instrument; }; 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; // --- 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 engine. 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