// reasampler_processor.h — VST3 SingleComponentEffect wiring the pure sampler core into // a playable instrument: event-input + stereo output bus, MIDI -> VoiceEngine, render. // Self-contained playback: component state owns per-sample WAV path + decode intrinsics // (SampleRefs); the bank blob is an opportunistic browser source, never a playback // dependency. Audio thread (process()) does no allocation/file-IO/bridge calls/locks; // loading happens off-thread (reloadInstrument) and hands off via one atomic pointer swap. #pragma once #include #include #include #include #include #include #include #include "public.sdk/source/vst/vstsinglecomponenteffect.h" #include "shell/instrument/reaper_bridge.h" #include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #include "core/instrument/map/component_state_io.h" // ComponentState codec #include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) #include "core/instrument/engine/voice_engine.h" namespace reasampler::vst { using instrument::map::ComponentState; using instrument::map::InstrumentParams; using instrument::map::SampleRefEntry; using instrument::map::SampleRefs; using instrument::map::kPreviewVelocityDefault; class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) // What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw // magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics), // because a hold timer or a log on the audio thread would be per-block work that buys nothing. struct MasterBusMeter { float peakL = 0.f; // max |x| this block float peakR = 0.f; // Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT // scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction) // rather than the mute's own weight. 1 = no reduction. float minGain = 1.f; bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it }; // The decoded capture + the voice engine playing it. The engine holds a reference to the // sample, so both must live/die together at a stable address — heap-allocated, // non-copyable, non-movable. process() only ever reads this through an atomic pointer. struct LoadedInstrument { SampleData sample; VoiceEngine engine; std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_ // Takeover declick is on by default here (product default; the pure core defaults it // off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead // of clicking. LoadedInstrument(SampleData sd, 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) : sample(std::move(sd)), engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames, voiceMode, monoTrigger, /*takeoverDeclick=*/true), installedAt(gen) {} // True when nothing in this snapshot is sounding; lets the off-thread retirer park an // idle drain early. Bounded scan (<= maxVoices). bool fullyIdle() const { return engine.activeVoiceCount() == 0; } 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 (unique_ptr, forward-declared here) is // complete 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 (a performance choice the instrument // owns; never written back to the bank). Component-state, so a saved 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; // The plugin's PDC report: 0 with the limiter bypassed, the limiter's lookahead with it // engaged. Read from the PERSISTED enable, never from a transient — the SDK's contract // (pluginterfaces/vst/ivsteditcontroller.h, kLatencyChanged) is that the host asks this // AFTER the deactivate/reactivate it performs, and setActive(false) clears the engine. Steinberg::uint32 PLUGIN_API getLatencySamples() override; // Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono // renders dual-mono through it. Do not reintroduce per-instance bus renegotiation. // Accepts only a single stereo output proposal; otherwise rejects and keeps stereo. 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; // Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the // inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged. Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override; // The embedded-strip activity level (0..1) for the embed shell, UI thread. The last block's // loudest channel — a non-consuming read, so it stays correct however often the editor's // meter drains its own accumulators (or never does, with no editor open). double embedActivityLevel() const { return static_cast(advisoryPeak_.load(std::memory_order_relaxed)); } // What the audio thread published since the LAST call: peaks maxed and minGain minimised // across every block in that window. CONSUMING — it resets the accumulators as it reads, so // exactly one reader may call it, and that reader is the editor's meter tick. UI thread. MasterBusMeter masterBusMeter(); void clearMasterBusClip(); // Resolves the selection against the instance-owned SampleRefs, decodes its WAV // off-thread, and publishes the built instrument via atomic swap — no bank read // required. When the bank blob is readable it's first folded into the refs table // (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A // missing/unreadable WAV is the defined no-play (silence, no retry). Returns the // resolved selection id ("" if nothing loaded). std::string reloadInstrument(); // The dialed sound as plain data for the offline bake: the SAME refs resolve + decode + // build reloadInstrument runs, with no live block attached. Rebuilt rather than copied // off the live snapshot because a tier-3 live edit leaves that snapshot's own play // params stale on purpose — copying it would bake the pre-drag values. nullopt when // nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O). std::optional bakeSnapshot(); // Adopt a landed bake in ONE act: the new capture becomes this instance's ref and // selection, the parameter set and master gain go neutral, and a single reload // publishes all three together. The ordering is the whole point — anything published // ahead of the re-point would apply neutral settings to the OLD capture, which is the // one thing they are meaningless against. UI thread only. void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset, double masterGainLinear); // What pollBankSync did this tick, so the editor can react only when something changed. struct BankSyncResult { bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded bool applied = false; // a new assignment request was applied -> selection changed }; // Off-thread poll (editor's UI timer only) of the bank generation + assignment request; // playback never depends on it. Generation change -> reload; a resolvable NEW assignment // targeting this instance (isFocusedTarget) -> apply as selection + reload (unresolvable // ones drop silently, marker still advances); pre-v10 legacy blobs retry the bank read // until the refs lift in, then stop (legacyLiftShouldRun). The consumed marker persists // so a re-open does not re-apply. Idempotent on an idle tick. 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 editor's envelope overlay // shares this time base. 0.0 before setupProcessing runs. double sampleRate() const { return sampleRate_; } // The loaded capture's id (guarded by selectionMutex_, never read on the audio thread). // Empty id -> silence, no first-sample fallback. std::string selectedSampleId(); void setSelectedSampleId(const std::string& id); // The one parameter set governing that capture. UI thread, guarded by paramsMutex_; // never read on the audio thread — reloadInstrument bakes it into the SampleData // off-thread. InstrumentParams instrumentParams(); void setInstrumentParams(const InstrumentParams& params); // Republishes the live-parameter block from the stored parameter set, resolved against the // rate the loaded capture was built at so an unmoved value folds to exactly the frames the // voices already latched. THE tier-3 commit (the three tiers are listed in this // directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they // paired it with reloadInstrument. No-op before anything has been decoded (the next reload // bakes and publishes). UI thread; serialized against reloadInstrument's own publish. void publishLiveParams(); // Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read // on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is // fixed stereo, so a mode change never renegotiates host I/O. ChannelMode channelMode(); // Editor toggle: latches the mode explicit (auto-default stops fighting it) and // reloads so the next block decodes the new channel count. UI thread only. void setChannelMode(ChannelMode mode); // Per-instance preview-trigger velocity (MIDI 1..127), guarded by previewMutex_, not // read on the audio thread. std::uint8_t previewVelocity(); void setPreviewVelocity(std::uint8_t velocity); // Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the // audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded SampleData, // no bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail. int voiceCount(); void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount VoiceMode voiceMode(); void setVoiceMode(VoiceMode mode); MonoTrigger monoTrigger(); void setMonoTrigger(MonoTrigger trigger); // Post-mixer master gain, linear in [0, masterGainMaxLinear()] (0 = true silence, 1 = // unity, cap +24 dB). Atomic — the audio thread applies it as a per-block post-sum // multiply, no lock, no rebuild. double masterGainLinear() const { return static_cast(masterGain_.load(std::memory_order_relaxed)); } void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] // The master-bus limiter's single enable (persisted in the parameter set). UI thread only: // a thin wrapper over setInstrumentParams, the one funnel that both mirrors the flag and // requests the host's kLatencyChanged restart, which the SDK requires be issued from the UI // thread and which process() must therefore never trigger. Setting the value it already // holds is a no-op, so repeated clicks on one segment cost no restart. bool limiterEnabled() const { return limiterEnabled_.load(std::memory_order_relaxed); } void setLimiterEnabled(bool on); // Fires a one-shot preview note-on/off through the live VoiceEngine — the same // noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio // thread; handed to process() via a lock-free single-slot mailbox drained at block // start. Never captures, never inserts a timeline item. void previewNoteOn(int note); void previewNoteOff(int note); // Snapshot copy of the instance-owned sample refs, for the editor's waveform/loop // fallback when the bank blob is unreadable. Guarded by refsMutex_. SampleRefs sampleRefs(); // This instance's usage identity, minted here if it has never published. It names BOTH // the "rsusage_" record the bake's tie query must exclude and the "rsbake_" request // key, so the two can never name different instances. Off the audio thread. std::string usageInstanceGuid(); private: // If process() published that the drain instrument is fully idle, move it into the // graveyard and prune — so an edited-away snapshot stops costing memory as soon as its // tails die. Off the audio thread only (driven by pollBankSync); safe against a racing // process() because idleness is monotone and the publication names the drain's own // installedAt (a stale value can never retire a newer occupant). void retireIdleDrain(); // Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's // already-decoded SampleData (no bridge/disk) and publishes through the same drain-slot // swap as a full reload. No-op when nothing is loaded. Off the audio thread only. void rebuildVoiceEngine(); // Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress // (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse). bool legacyLiftShouldRun(); // Publishes `built` (null = install silence) into live_: prunes the graveyard by the // last process()-published generation, swaps `built` into live_, displaces the previous // live into the drain slot, and parks the evicted drain instrument in the graveyard. // Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. void publishBuiltLocked(std::unique_ptr built); // Folds one block's reading into the accumulator it belongs to — a running max for a peak, // a running min for the limiter's gain. Read-modify-write rather than a bare store, so the // ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512 all reach the meter // instead of the one it happened to sample. Relaxed throughout: the accumulators are // advisory, and no other state is ordered against them. Block rate, never per frame. static void foldPeak(std::atomic& acc, float blockPeak) { if (blockPeak > acc.load(std::memory_order_relaxed)) { acc.store(blockPeak, std::memory_order_relaxed); } } static void foldMinGain(std::atomic& acc, float blockMinGain) { if (blockMinGain < acc.load(std::memory_order_relaxed)) { acc.store(blockMinGain, std::memory_order_relaxed); } } // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears // only the ADVISORY level, which is a last-block reading: the meter accumulators need // nothing here, because a block that emitted no audio contributes no peak and no gain // reduction, and the UI's own read is what resets them. void publishSilentMeterBlock() { advisoryPeak_.store(0.f, std::memory_order_relaxed); } // Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called // from every writer of the parameter set, so the three views can never disagree. void publishLimiterEnabled(bool on); // Publishes this instance's held captures to its per-instance ext-state key // ("rsusage_") so the extension's prune can never reclaim them. Called at // the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on // first need; re-mints on a detected clone (FX copy / track duplication). void publishUsage(const SampleRefs& refs, const std::vector& ids); ReaperBridge bridge_; // The ONE live-parameter block for this instance, declared ahead of the instrument slots // so it outlives every snapshot that points at it (members destruct in reverse order). // Both live_ and draining_ observe this same block — a block owned by a snapshot would // leave the drain's still-sounding voices deaf to the knob under them. instrument::engine::LiveParams liveParams_; // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) // only — separate from reloadMutex_ so a knob drag's publish never blocks behind a // reload's WAV decode. The audio thread never takes this; process() only reads via // LiveParams::read's lock-free seqlock retry. std::mutex livePublishMutex_; // The rate the loaded capture was decoded/built at, so a live republish resolves the // stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing // built yet. // // ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload // publishes the new block before installing the new instrument. Swapping to a capture at a // different rate therefore hands drain voices still ringing from the old-rate capture // envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap). // Unavoidable while one block sits above every snapshot, and it touches a release tail // only. std::atomic builtSampleRate_{0}; // --- The audio-thread handoff (drain slot) --- // process() atomically loads live_ + draining_ at block start (two acquires, no lock). // reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into // live_; the displaced instrument moves to draining_, where process() keeps rendering // its already-sounding voices (and routes note-offs to it) so a reload never cuts a // ringing note — new note-ons go only to live_. The instrument evicted from draining_ // (two reloads old) parks in graveyard_ for reclaim. // // Reclaim: process() publishes the minimum installedAt it holds via processGeneration_ // (one relaxed store); the reload path frees graveyard entries older than that. Safe // because both slots are monotone in installedAt, so the published minimum is monotone // and an entry only reaches the graveyard after leaving both slots under reloadMutex_ — // an entry below the published minimum can never be loaded again. // // Graveyard upper bound: reloads since process last ran (typically 0-1). Remaining // entries drain at setActive(false) / terminate(), when process is guaranteed 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) // The installedAt of the drain instrument process() last observed fully idle (0 = none / // 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 // A master gain that must reach the audio thread in the SAME swap as the next publish — // the bake's reset, which the old capture would be the wrong thing to apply it to. // Guarded by reloadMutex_, consumed by publishBuiltLocked. std::optional gainAtNextPublish_; // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the // audio thread. std::mutex selectionMutex_; std::string selectedSampleId_; // The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData // under the reload lock, never read directly on the audio thread. std::mutex paramsMutex_; InstrumentParams params_; // Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed // opportunistically from the bank blob when readable; never a bank dependency for // playback. Off-thread only. std::mutex refsMutex_; SampleRefs sampleRefs_; // Usage-publish identity (see publishUsage). instanceGuid_ is the persisted per-instance // identity; usageNonce_ is this incarnation's per-lifetime owner nonce (never persisted — // a persisted nonce would clone with the state on FX copy, letting a sibling clean- // replace over another's held paths). Minted lazily; cleared on setState. std::mutex usageMutex_; std::string instanceGuid_; std::string usageNonce_; // Per-instance channel mode, default Mono; not read on the audio thread (process // renders against the host's negotiated channel count). channelModeExplicit_: false = // reloadInstrument may auto-default the mode from the loaded capture; true = the user // deliberately toggled it (never fought thereafter). std::mutex channelModeMutex_; ChannelMode channelMode_ = ChannelMode::Mono; bool channelModeExplicit_ = false; // The last assignment-request generation consumed, persisted so a re-open does not // re-apply a stale request. 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. UI/timer-thread only (pollBankSync's sole // reader/writer), not persisted. -1 sentinel baselines the first poll without a // redundant reload; a later generation change then drives the reload. std::int64_t lastSeenBankGeneration_ = -1; // Legacy-lift terminating latch: set once legacyLiftShouldRun proves the referenced ids // stale against a readable bank blob, so the lift stops re-firing every tick. Reset by // setState (a new blob = new facts). std::atomic legacyLiftConcluded_{false}; // Preview-trigger velocity (MIDI 1..127, persisted). Default kPreviewVelocityDefault // (64). Not read on the audio thread. std::mutex previewMutex_; std::uint8_t previewVelocity_ = kPreviewVelocityDefault; // Voice-system parameters (per-instance, persisted). Defaults {16, Poly, Retrigger}. // Not read on the audio thread — reloadInstrument bakes them into the engine off-thread. std::mutex voiceParamsMutex_; int voiceCount_ = kDefaultVoiceCount; VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; // Post-mixer master gain (linear, persisted). Lock-free atomic target; the audio thread // ramps gainCurrent_ toward it per-sample (~20 ms wall-clock at every host rate) so // knob moves produce no zipper noise. std::atomic masterGain_{1.0f}; // Audio-thread running gain value, stepping at most gainRampStep_ per sample toward the // target. Written/read exclusively on the audio thread — no atomics needed. float gainCurrent_ = 1.0f; // Per-sample ramp step derived from kGainRampSeconds against the live host rate in // setupProcessing — never a baked-in rate. Default is the 48 kHz value. float gainRampStep_ = 1.0f / 960.0f; // --- Preview-trigger mailbox (off-thread -> audio thread, lock-free) ----------------- // One slot per direction, packed as (seq << 16) | (velocity << 8) | note [on] or // (seq << 16) | note [off]. process() detects a new request by comparing the packed // sequence against the last one consumed — a single relaxed atomic load per block, // RT-safe (no alloc, no lock). 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; 0.0 is explicitly invalid (reloadInstrument guards on it). double sampleRate_ = 0.0; Steinberg::int32 maxBlockSize_ = 4096; // The embed shell, created lazily on the first queryInterface and owned here for the // processor's lifetime; REAPER's borrowed AddRef'd references are outlived by this // unique_ptr, so its own refcount is a no-op. std::unique_ptr embed_; // The master-bus limiter, applied post-gain over the summed output. Its own enable target // is the mirror of params_.limiterEnabled; limiterEnabled_ is the lock-free copy // getLatencySamples answers from. instrument::engine::Limiter limiter_; std::atomic limiterEnabled_{false}; // What the audio thread publishes about the output bus each block, relaxed. The peaks and // minGain ACCUMULATE (max / min) across every block since the UI last read, and // masterBusMeter() resets them as it reads — the fix for a bar that displayed roughly one // block in fifty. No dB, no ballistics, no hold timer here; // the UI runs those off these values and its own elapsed time. std::atomic meterPeakL_{0.f}; std::atomic meterPeakR_{0.f}; std::atomic meterMinGain_{1.f}; std::atomic meterClip_{false}; // The embed strip's activity level: the LAST block's loudest channel, plainly overwritten. // Separate from the accumulators above because it answers a different question ("how loud // is it now") and has a different reader — sharing one would make each reader's reset // silently truncate the other's window. std::atomic advisoryPeak_{0.f}; }; } // namespace reasampler::vst