Files
reasampler/src/vst/reasampler_processor.h
T

412 lines
26 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#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
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),
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<double>(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<double>(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<LoadedInstrument> 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 01 in normal use). Remaining entries drain at setActive(false) /
// terminate(), when the host guarantees process is stopped.
std::atomic<LoadedInstrument*> live_{nullptr};
std::atomic<LoadedInstrument*> draining_{nullptr}; // displaced instrument still rendering its tails
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
// 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};
std::vector<std::unique_ptr<LoadedInstrument>> 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 ONE voice-param the audio thread reads directly (a single relaxed load
// per block, applied as a post-sum multiply). Default unity = pre-FB1 output.
std::atomic<float> masterGain_{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<std::uint32_t> previewOnRequest_{0}; // 0 = no request posted yet
std::atomic<std::uint32_t> 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<ReaSamplerEmbed> 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<float> embedPeak_{0.f};
};
} // namespace reasampler::vst