// reasampler_processor.cpp — see reasampler_processor.h. #include "reasampler_processor.h" #include #include #include #include #include #include #include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" #include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/vstspeaker.h" #include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) #include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) #include "reasampler_editor.h" #include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser #include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; using namespace Steinberg::Vst; namespace reasampler::vst { namespace { // S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is // materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather // than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice // cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the // polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays // FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. constexpr std::size_t kPreserveVoiceCap = 8; // FB1 post-mixer gain ramp rate (per sample). gainCurrent_ converges to masterGain_ at this // linear step; it ramps from 0 to unity (or vice versa) in ~20 ms at 48 kHz. The early-out // (|current - target| < threshold) snaps to the target and avoids the ramp loop on idle blocks. // kGainRampSnap is the threshold below which we snap to the target (avoids long sub-LSB creep). constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms constexpr float kGainRampSnap = kGainRampRate * 0.5f; // Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on // any failure — the caller treats an unreadable WAV as "nothing to play". std::vector readFileBytes(const std::string& path) { std::vector bytes; std::ifstream f(path, std::ios::binary | std::ios::ate); if (!f) return bytes; const std::streamoff size = f.tellg(); if (size <= 0) return bytes; f.seekg(0, std::ios::beg); bytes.resize(static_cast(size)); if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); return bytes; } // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file // I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode // downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for // a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path // fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no // frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by // the zoned build and the single-capture path so both decode identically for the active mode. std::optional decodeRelative(const std::string& projectDir, const std::string& relativePath, ChannelMode mode) { const std::string abs = resolveBankFile(projectDir, relativePath); if (abs.empty()) return std::nullopt; const std::vector bytes = readFileBytes(abs); const WavLayout layout = parseWavLayout(bytes); if (!layout.valid) return std::nullopt; std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, static_cast(layout.sampleRate)); if (out.monoFrames.empty()) return std::nullopt; return out; } } // namespace FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { // The host owns the returned reference. Cast up to the combined interface the SDK // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. return static_cast(new ReaSamplerProcessor()); } // Out-of-line so unique_ptr sees the complete type here. ReaSamplerProcessor::~ReaSamplerProcessor() = default; tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed // shell. We own the shell (unique_ptr); the borrowed reference is valid because the // processor outlives it. All other iids fall through to the SDK's queryInterface. if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { if (!embed_) embed_ = std::make_unique(this); embed_->addRef(); *obj = static_cast(embed_.get()); return kResultOk; } return SingleComponentEffect::queryInterface(iid, obj); } tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { tresult result = SingleComponentEffect::initialize(context); if (result != kResultOk) return result; // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the // instrument still loads, it just has no live bank to play. bridge_.connect(context); // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders // dual-mono through the stereo bus (both channels equal, centered), which is audibly // identical to a mono bus but never asks the host to re-map a live instance's pins. The // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's // re-routing of the live instance's pins across the arrangement change. A fixed arrangement // is the maximally-standard VSTi shape and removes that whole negotiation surface. addEventInput(STR16("MIDI In"), 16); addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::terminate() { // process() is not running at terminate. Free the live + draining instruments and // drain the graveyard. Take the pointers out of the atomics first so nothing else // races them. std::lock_guard lock(reloadMutex_); delete live_.exchange(nullptr); delete draining_.exchange(nullptr); graveyard_.clear(); return SingleComponentEffect::terminate(); } tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { // Activating: build the instrument from the currently-selected sample so the first // block after activation can play. Deactivating: process is now GUARANTEED stopped by // the host, so this is the safe point to reclaim the graveyard (the displaced engines // no reload could free while active). The build/drain are off the audio thread — // setActive is a main/UI-thread call. if (state) { reloadFromBank(); } else { std::lock_guard lock(reloadMutex_); // process is guaranteed stopped: free EVERYTHING. The live instrument too — its // voices are frozen mid-flight, and if it survived deactivation the reactivate // reload would displace it into the DRAIN slot, resurrecting stale sustained // voices as ghosts. Reactivation rebuilds from scratch (reloadFromBank above), // so nothing is lost by clearing here. delete live_.exchange(nullptr); delete draining_.exchange(nullptr); graveyard_.clear(); } return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { sampleRate_ = setup.sampleRate; maxBlockSize_ = setup.maxSamplesPerBlock; return SingleComponentEffect::setupProcessing(setup); } tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { if (!state) return kResultFalse; // Read the whole component-state blob (the performance map, versioned). The blob is // small; read in one shot into a growable buffer. std::vector bytes; std::uint8_t chunk[256]; int32 got = 0; while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); } // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The // selection and the zones are DISTINCT — the default face is one picked capture, zones // are a demoted overlay — so both are restored explicitly (no more inferring a selection // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 // silent empty state (no first-sample fallback in reloadFromBank). // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). const ComponentState cs = deserializeComponentState(bytes, sampleRate_); setSelectedSampleId(cs.selectionId); // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the // saved selection under first-match resolve. Reconciling here restores "the sample the // editor shows is the sample the engine plays" for already-affected projects; authored // Zone-view maps (any narrow key range) pass through untouched. PerformanceMap restored = cs.map; reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadFromBank run unconditionally on load setPerformanceMap(restored); // S8: restore the last-consumed assignment generation so a re-open does not re-apply a // stale assign_request (the user may have manually changed the selection after the assign). { std::lock_guard lock(assignMarkerMutex_); lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; } // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see // initialize) — the mode only governs how the reload below decodes, so no bus work here. { std::lock_guard lock(channelModeMutex_); channelMode_ = cs.channelMode; channelModeExplicit_ = cs.channelModeExplicit; } // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 // the editor's velocity knob is a concurrent UI-thread writer. { std::lock_guard lock(previewMutex_); previewVelocity_ = cs.previewVelocity; } // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the // reload below so the rebuilt engine is born with the saved polyphony/mode. { std::lock_guard lock(voiceParamsMutex_); voiceCount_ = cs.voiceCount; voiceMode_ = cs.voiceMode; monoTrigger_ = cs.monoTrigger; } // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks // it up at the next block start. setMasterGainLinear(cs.masterGainLinear); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; // Persist the full instance state (v3, S10): the single-capture selection id AND the // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing // sample #1. ComponentState state_out; state_out.selectionId = selectedSampleId(); state_out.map = performanceMap(); { // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). std::lock_guard lock(channelModeMutex_); state_out.channelMode = channelMode_; state_out.channelModeExplicit = channelModeExplicit_; } { std::lock_guard lock(assignMarkerMutex_); state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker } state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity { // Phase S: persist the voice-system parameters (component state v7). std::lock_guard lock(voiceParamsMutex_); state_out.voiceCount = voiceCount_; state_out.voiceMode = voiceMode_; state_out.monoTrigger = monoTrigger_; } state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), static_cast(bytes.size()), nullptr); if (wr != kResultOk) return wr; } return kResultOk; } std::string ReaSamplerProcessor::selectedSampleId() { std::lock_guard lock(selectionMutex_); return selectedSampleId_; } void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { std::lock_guard lock(selectionMutex_); selectedSampleId_ = id; } PerformanceMap ReaSamplerProcessor::performanceMap() { std::lock_guard lock(performanceMutex_); return performanceMap_; } void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { std::lock_guard lock(performanceMutex_); performanceMap_ = map; } ChannelMode ReaSamplerProcessor::channelMode() { std::lock_guard lock(channelModeMutex_); return channelMode_; } std::uint8_t ReaSamplerProcessor::previewVelocity() { std::lock_guard lock(previewMutex_); return previewVelocity_; } void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. if (velocity < 1) velocity = 1; if (velocity > 127) velocity = 127; std::lock_guard lock(previewMutex_); previewVelocity_ = velocity; } int ReaSamplerProcessor::voiceCount() { std::lock_guard lock(voiceParamsMutex_); return voiceCount_; } void ReaSamplerProcessor::setVoiceCount(int count) { // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's // control can never disagree about the legal polyphony span. if (count < kMinVoiceCount) count = kMinVoiceCount; if (count > kMaxVoiceCount) count = kMaxVoiceCount; { std::lock_guard lock(voiceParamsMutex_); if (voiceCount_ == count) return; // no-op: don't churn a rebuild voiceCount_ = count; } // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change // touches no audio data) and the displaced instrument keeps rendering its ringing tails, // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every // zone from disk. Same contract for the mode/trigger setters below. rebuildVoiceEngine(); } VoiceMode ReaSamplerProcessor::voiceMode() { std::lock_guard lock(voiceParamsMutex_); return voiceMode_; } void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { { std::lock_guard lock(voiceParamsMutex_); if (voiceMode_ == mode) return; voiceMode_ = mode; } rebuildVoiceEngine(); } MonoTrigger ReaSamplerProcessor::monoTrigger() { std::lock_guard lock(voiceParamsMutex_); return monoTrigger_; } void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { { std::lock_guard lock(voiceParamsMutex_); if (monoTrigger_ == trigger) return; monoTrigger_ = trigger; } rebuildVoiceEngine(); } void ReaSamplerProcessor::setMasterGainLinear(double linear) { // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; // no rebuild, no lock (a post-sum output trim is not a keymap fact). if (!(linear >= 0.0)) linear = 0.0; // also catches NaN const double maxLin = masterGainMaxLinear(); if (linear > maxLin) linear = maxLin; masterGain_.store(static_cast(linear), std::memory_order_relaxed); } void ReaSamplerProcessor::previewNoteOn(int note) { if (note < 0) note = 0; if (note > 127) note = 127; const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as // long as we never land back on the exact value the audio thread last consumed in one step — // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; const std::uint32_t packed = (static_cast(seq) << 16) | (static_cast(vel) << 8) | static_cast(note & 0xFF); previewOnRequest_.store(packed, std::memory_order_release); } void ReaSamplerProcessor::previewNoteOff(int note) { if (note < 0) note = 0; if (note > 127) note = 127; const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; const std::uint32_t packed = (static_cast(seq) << 16) | static_cast(note & 0xFF); previewOffRequest_.store(packed, std::memory_order_release); } void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { { std::lock_guard lock(channelModeMutex_); // The editor toggle is a DELIBERATE choice either way: latch explicit even on a // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). channelModeExplicit_ = true; if (channelMode_ == mode) return; // no decode change: don't churn a reload channelMode_ = mode; } // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. reloadFromBank(); } tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( SpeakerArrangement* inputs, int32 numIns, SpeakerArrangement* outputs, int32 numOuts) { // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. if (numIns < 0 || numOuts < 0) return kInvalidArgument; if (numIns > 0) return kResultFalse; // no audio input bus to arrange if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; return kResultFalse; } std::string ReaSamplerProcessor::reloadFromBank() { // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so // the retired-slot free is single-writer. This mutex is NEVER taken on the audio // thread — process() only touches the atomic. std::lock_guard lock(reloadMutex_); // Mint this reload's generation number first so we can stamp the built instrument // with it before publishing. Under reloadMutex_ no other reload races here. const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; // 1. Read the live bank + resolve the project dir over the bridge (allocates, // calls REAPER — fine here, off-thread). std::optional banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey); const std::string projectDir = bridge_.activeProjectDir(); // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). // Read once under its mutex, off the audio thread, before the decode loop. The single- // capture branch below may auto-default it (GA) before its decode. ChannelMode mode = channelMode(); // Phase S: snapshot the voice-system parameters once — they are baked into the built // engine's construction (the engine's config is immutable; a later change rebuilds). int builtVoiceCount = kDefaultVoiceCount; VoiceMode builtVoiceMode = VoiceMode::Poly; MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; { std::lock_guard vp(voiceParamsMutex_); builtVoiceCount = voiceCount_; builtVoiceMode = voiceMode_; builtMonoTrigger = monoTrigger_; } std::string resolvedId; std::unique_ptr built; if (banksJson) { // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its // zones against the live bank (STALE ids drop cleanly), decode each zone's WAV // off-thread, and build the ZONED keymap. Each surviving zone plays its bank // sample repitched from its effective root note (override > bank intrinsic > C4). // A zone whose WAV fails to decode is dropped (not the whole map). const PerformanceMap map = performanceMap(); Keymap km; bool haveKeymap = false; if (!map.empty()) { const ResolvedPerformance resolved = resolvePerformance(*banksJson, map); if (!resolved.zones.empty()) { std::vector decoded; std::vector kept; decoded.reserve(resolved.zones.size()); kept.reserve(resolved.zones.size()); for (const ResolvedZone& rz : resolved.zones) { std::optional pcm = decodeRelative(projectDir, rz.relativePath, mode); if (!pcm) continue; // unreadable WAV -> drop this zone kept.push_back(rz); decoded.push_back(std::move(*pcm)); } km = buildZonedKeymap(kept, decoded); haveKeymap = !km.zones.empty(); } } // 3. Single-capture fast path (S10): an empty performance map plays the ONE // deliberately-selected capture chromatically across the whole keyboard. This is // the default face — one picked capture, repitched from its root. NO first- // sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in // selectSample, so an un-picked instrument stays SILENT (the editor shows its // "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy // reversal of the S4 convenience default). if (!haveKeymap) { std::optional sel = selectSample(*banksJson, selectedSampleId()); if (sel) { // GA auto-default: while the channel mode is IMPLICIT (never user-toggled), // follow the loaded capture's channel count — a stereo capture decodes (and // shows) Stereo, a mono one Mono. An unknown count (0, an older bank entry) // changes nothing; an explicit user choice is never fought. Decode-only: the // output bus is fixed stereo, so no bus work follows a flip. if (sel->channelCount > 0) { const ChannelMode desired = sel->channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; std::lock_guard lock(channelModeMutex_); if (!channelModeExplicit_) channelMode_ = desired; mode = channelMode_; } std::optional pcm = decodeRelative(projectDir, sel->relativePath, mode); if (pcm) { km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, sel->rootNote, sel->loop, std::move(pcm->framesR)); haveKeymap = true; resolvedId = selectedSampleId(); // the concrete pick that resolved } } } if (haveKeymap) { // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). // Every voice's shifter is pre-sized to this off-thread here, so process()-time // note-on never allocates. Floored at 2 so a valid window is always a real ring // (which also covers a pathological host rate <= 0 — no rate literal needed). std::int64_t preserveWindow = static_cast( kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; built = std::make_unique( std::move(km), static_cast(builtVoiceCount), gen, kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); } } // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — // a reload never cuts a sounding note; the next note-on plays the new state. The // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard // (process may still be mid-block reading it). A null `built` (no bank / unreadable // WAV) installs silence while the displaced tails still ring out via the drain. // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted // pointer is re-owned by the graveyard. // publishBuiltLocked(std::move(built)); return resolvedId; } void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by // reloadFromBank and rebuildVoiceEngine — the one safety-critical swap dance. // // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is // the minimum installedAt process() published over the pointers it holds. Both // slots are monotone in installedAt, so seen is monotone and any future process() // load yields installedAt >= seen — an entry below seen is provably unreachable // (see the header proof). Remaining entries drain at setActive(false) / terminate() // when process is guaranteed stopped. const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), [seen](const std::unique_ptr& e) { return e->installedAt < seen; }), graveyard_.end()); LoadedInstrument* prev = live_.exchange(built.release()); LoadedInstrument* evicted = draining_.exchange(prev); if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); } void ReaSamplerProcessor::rebuildVoiceEngine() { // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: // a voice-param change touches NO audio data, so this rebuilds the engine + preview card // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — // and publishes through the same drain-slot swap, so ringing tails survive. std::lock_guard lock(reloadMutex_); LoadedInstrument* cur = live_.load(std::memory_order_acquire); if (!cur) return; // nothing loaded: the new params bake into the next real reload. int builtVoiceCount = kDefaultVoiceCount; VoiceMode builtVoiceMode = VoiceMode::Poly; MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; { std::lock_guard vp(voiceParamsMutex_); builtVoiceCount = voiceCount_; builtVoiceMode = voiceMode_; builtMonoTrigger = monoTrigger_; } const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; // Same Preserve-window derivation as reloadFromBank (kPreserveWindowMs at the host rate). std::int64_t preserveWindow = static_cast( kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap // is immutable after construction, and under reloadMutex_ nobody can free `cur`. Keymap km = cur->keymap; auto built = std::make_unique( std::move(km), static_cast(builtVoiceCount), gen, kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); publishBuiltLocked(std::move(built)); } void ReaSamplerProcessor::retireIdleDrain() { // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or // it still sounds" — the common case costs one relaxed load and no mutex. const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); if (idleGen == 0) return; std::lock_guard lock(reloadMutex_); LoadedInstrument* drain = draining_.load(std::memory_order_acquire); // Retire ONLY if the publication names the drain currently in the slot. A stale value // (about an already-evicted, older drain) can never match the newer occupant's // installedAt — the slot is monotone in generation — so a mid-swap race is closed by // this identity check, not by timing. if (!drain || drain->installedAt != idleGen) return; draining_.store(nullptr, std::memory_order_release); graveyard_.push_back(std::unique_ptr(drain)); // Prune what is now provably unreachable — the same monotone-generation proof as the // reload path's reclaim (see reloadFromBank): an entry with installedAt < seen cannot be // held by process() now or ever again. The just-parked drain frees here immediately when // process() has already published past it; otherwise on the next reload/retire/deactivate. const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), [seen](const std::unique_ptr& e) { return e->installedAt < seen; }), graveyard_.end()); } ReaSamplerProcessor::BankSyncResult ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. BankSyncResult result; // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer // cadence that drives reloads — an edited-away instrument stops costing memory as soon // as its tails die instead of squatting in the drain slot until the next reload. retireIdleDrain(); // --- S8: assignment-request consume FIRST ------------------------------------- // Decode the pending assignment request (nullopt when absent/malformed). Resolve its // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when // the sampleId names an existing sample (the reader requirement — an unresolvable pair is // dropped). Then run the pure consume decision against this instance's persisted marker. std::optional request; if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { request = decodeAssignmentRequest(*raw); } bool resolves = false; if (request) { // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { resolves = selectSample(*banksJson, request->sampleId).has_value(); } } // Read lastConsumed and conditionally write it back under a single lock scope so there // is no interleave window between the read and the write (a concurrent getState could // otherwise observe a stale marker between the two separate lock acquisitions). std::int64_t lastConsumed = 0; const AssignConsumeDecision decision = [&] { std::lock_guard lock(assignMarkerMutex_); lastConsumed = lastConsumedAssignGeneration_; const AssignConsumeDecision d = consumeDecision(request, lastConsumed, resolves, isFocusedTarget); // Advance the persisted consumed marker whenever the decision consumed the request // (applied OR dropped-as-seen). getState will persist it on the next project save so // a re-open does not re-apply. A non-target instance leaves the marker (decision // returns it unchanged) so it stays eligible if focus later lands here. if (d.consumedGeneration != lastConsumed) { lastConsumedAssignGeneration_ = d.consumedGeneration; } return d; }(); if (decision.apply) { // Apply the assignment as this instance's own selection (the same path a user card-pick // takes) — the instrument updates its OWN state, never the bank. reloadFromBank below // rebuilds against the new selection, so skip a redundant reload here. setSelectedSampleId(decision.sampleId); // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone // materialized for the previously loaded sample would shadow the assigned pick under // first-match resolve. Authored maps (any narrow key range) are untouched. PerformanceMap reconciled = performanceMap(); if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { setPerformanceMap(reconciled); } result.applied = true; } // --- S9: bank-generation change-detection ------------------------------------- // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — setState // already loaded the current bank, so a redundant reload on open would only churn. A later // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). std::int64_t currentGen = kBankGenerationAbsent; if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { currentGen = parseBankGeneration(*rawGen); } const bool firstPoll = (lastSeenBankGeneration_ < 0); const bool genChanged = !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); lastSeenBankGeneration_ = currentGen; if (genChanged || result.applied) { reloadFromBank(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) result.reloaded = genChanged; // report S9 vs S8 distinctly for the editor's reaction } return result; } tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt // over the pointers held so the off-thread graveyard pruner knows exactly which // generations this block is holding (see the header proof). // // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an // ordering race: reading reloadGeneration_ after the slots could observe a generation // newer than the pointers we actually hold, causing the pruner to free an instrument // process is still reading. installedAt was set on the reload path before the atomic // exchange that made the instrument visible. // // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the // last reload: its already-sounding voices keep rendering (and receive note-offs) so a // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. // A racing reload can briefly leave the same pointer in both slots (live_ was loaded // before the swap, draining_ after); collapse that to live-only so one engine is never // advanced twice per frame. LoadedInstrument* inst = live_.load(std::memory_order_acquire); LoadedInstrument* drain = draining_.load(std::memory_order_acquire); if (drain == inst) drain = nullptr; std::uint64_t heldGen = 0; if (inst && drain) { heldGen = inst->installedAt < drain->installedAt ? inst->installedAt : drain->installedAt; } else if (inst) { heldGen = inst->installedAt; } else if (drain) { heldGen = drain->installedAt; } processGeneration_.store(heldGen, std::memory_order_release); // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine // voice AND its preview card silent) by naming its OWN installedAt (0 = no drain / still // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. drainIdleGeneration_.store( (drain && drain->fullyIdle()) ? drain->installedAt : 0, std::memory_order_relaxed); // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps // events at block granularity (no per-event sample-offset split) — audible timing is // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. // Note-offs also route to the DRAIN engine so a note held across a reload releases // its old-snapshot voice too (otherwise it would sustain until the next reload). if (data.inputEvents) { const int32 count = data.inputEvents->getEventCount(); for (int32 i = 0; i < count; ++i) { Event e; if (data.inputEvents->getEvent(i, e) != kResultOk) continue; if (e.type == Event::kNoteOnEvent) { // A note-on with velocity 0 is a note-off by MIDI convention. const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); if (vel <= 0) { if (inst) inst->engine.noteOff(e.noteOn.pitch); if (drain) drain->engine.noteOff(e.noteOn.pitch); } else if (inst) { inst->engine.noteOn(e.noteOn.pitch, vel); } } else if (e.type == Event::kNoteOffEvent) { if (inst) inst->engine.noteOff(e.noteOff.pitch); if (drain) drain->engine.noteOff(e.noteOff.pitch); } else if (e.type == Event::kLegacyMIDICCOutEvent) { // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism // — the type is nominally an output event; DAW-verify, see handoff). // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR // release tail; Trigger one-shots play through their bounded play length. // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless // of play mode, including Trigger one-shots that ignore CC 123. This is the // true "panic" for a ringing one-shot (e.g. a full-length capture). // Both clear the mono held stack. Both apply to live AND drain, engine AND preview. // allNotesOff / allSoundsOff / releaseAll / hardStop are RT-safe (no allocation, // bounded scans). const auto cc = static_cast(e.midiCCOut.controlNumber); if (cc == kCtrlAllSoundsOff) { if (inst) { inst->engine.allSoundsOff(); inst->preview.hardStop(); } if (drain) { drain->engine.allSoundsOff(); drain->preview.hardStop(); } } else if (cc == kCtrlAllNotesOff) { if (inst) { inst->engine.allNotesOff(); inst->preview.releaseAll(); } if (drain) { drain->engine.allNotesOff(); drain->preview.releaseAll(); } } } } } // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last // one we consumed; fire it once, then latch the sequence so the same request never re-fires. // Phase S: preview note-on/off drive the dedicated PREVIEW CARD — a single voice structurally // OUTSIDE the MIDI pool, so a full pool can never drop a preview and a preview can never // steal a playing MIDI voice (the FA1-review isolation fix). Host MIDI routes ONLY to the // engine (above); the card is summed alongside it in the render below. // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument // is loaded does not re-fire stale on the next instrument load. { const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); const std::uint16_t onSeq = static_cast(on >> 16); if (onSeq != 0 && onSeq != previewOnConsumed_) { previewOnConsumed_ = onSeq; if (inst) { const int vel = static_cast((on >> 8) & 0xFF); const int note = static_cast(on & 0xFF); if (vel > 0) inst->preview.noteOn(note, vel); } } } if (inst || drain) { const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); const std::uint16_t offSeq = static_cast(off >> 16); if (offSeq != 0 && offSeq != previewOffConsumed_) { previewOffConsumed_ = offSeq; // Route the preview note-off to BOTH cards (mirror of the host note-off): a // preview held across a reload — e.g. a curve edit committed mid-press — must // release the old-snapshot card now draining, not just the (fresh) live one. if (inst) inst->preview.noteOff(static_cast(off & 0xFF)); if (drain) drain->preview.noteOff(static_cast(off & 0xFF)); } } if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { embedPeak_.store(0.f, std::memory_order_relaxed); return kResultOk; } AudioBusBuffers& out = data.outputs[0]; const int32 frames = data.numSamples; // 64-bit host processing is not supported by the mono float core; emit silence // rather than mis-render. REAPER runs 32-bit float by default. if (data.symbolicSampleSize != kSample32) { embedPeak_.store(0.f, std::memory_order_relaxed); for (int32 ch = 0; ch < out.numChannels; ++ch) { if (double* buf = out.channelBuffers64[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; } } out.silenceFlags = (out.numChannels >= 64) ? ~0ULL : ((1ULL << out.numChannels) - 1); return kResultOk; } // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here // we simply match the buffers the host handed us: >=2 channels -> true stereo render into // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; // The PLUG-IN owns output silenceFlags (VST3 contract). Claim non-silence on every rendered // block — a stale host-side flag left unwritten could mute a channel downstream (GA). out.silenceFlags = 0; if (ch0 && ch1) { // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo // path (both channels equal), so a mono capture in stereo mode is centered, not silent. // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } if (inst) { inst->engine.render(ch0, ch1, static_cast(frames)); inst->preview.render(ch0, ch1, static_cast(frames)); } if (drain) { drain->engine.render(ch0, ch1, static_cast(frames)); drain->preview.render(ch0, ch1, static_cast(frames)); } // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the // actual output. Branch-free inner loop; early-out when already at target. RT-safe. { const float gTarget = masterGain_.load(std::memory_order_relaxed); const float diff = gTarget - gainCurrent_; if (diff < -kGainRampSnap || diff > kGainRampSnap) { // Ramp toward target: step per sample, then apply the per-sample gain. for (int32 i = 0; i < frames; ++i) { const float d = gTarget - gainCurrent_; if (d > kGainRampRate) gainCurrent_ += kGainRampRate; else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate; else gainCurrent_ = gTarget; ch0[i] *= gainCurrent_; ch1[i] *= gainCurrent_; } } else { gainCurrent_ = gTarget; if (gTarget != 1.f) { for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } } } } // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; } } // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; if (a0 > peak) peak = a0; if (a1 > peak) peak = a1; } embedPeak_.store(peak, std::memory_order_relaxed); } else if (ch0) { // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; // the replicate is defensive for a host that still hands >1 channel on a mono bus). for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; if (inst) { inst->engine.render(ch0, static_cast(frames)); inst->preview.render(ch0, static_cast(frames)); } if (drain) { drain->engine.render(ch0, static_cast(frames)); drain->preview.render(ch0, static_cast(frames)); } // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. { const float gTarget = masterGain_.load(std::memory_order_relaxed); const float diff = gTarget - gainCurrent_; if (diff < -kGainRampSnap || diff > kGainRampSnap) { for (int32 i = 0; i < frames; ++i) { const float d = gTarget - gainCurrent_; if (d > kGainRampRate) gainCurrent_ += kGainRampRate; else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate; else gainCurrent_ = gTarget; ch0[i] *= gainCurrent_; } } else { gainCurrent_ = gTarget; if (gTarget != 1.f) { for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; } } } float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; if (a > peak) peak = a; } embedPeak_.store(peak, std::memory_order_relaxed); for (int32 ch = 1; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; } } } // Report silence only when nothing is loaded (lets the host optimize when idle). // With an instrument loaded — or a drain snapshot still ringing out — we clear the // flag so a ringing voice is not skipped. out.silenceFlags = (inst || drain) ? 0 : ((out.numChannels >= 64) ? ~0ULL : ((1ULL << out.numChannels) - 1)); return kResultOk; } IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { if (name && FIDStringsEqual(name, ViewType::kEditor)) { return new ReaSamplerEditor(this); } return nullptr; } } // namespace reasampler::vst