// 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/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate) #include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/vstspeaker.h" #include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr) #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 "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; // 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. The output arrangement follows the instance's channel mode (S7) — mono by // default (kMono), stereo (kStereo) when the mode is stereo. addAudioOutput needs an initial // arrangement; seed it at the mode's arrangement so getBusInfo is correct from the first // query. (setState may later flip the mode and re-negotiate via setChannelMode.) addEventInput(STR16("MIDI In"), 16); const ChannelMode mode = channelMode(); addAudioOutput(STR16("Audio Out"), mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono); 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 and point the output bus at its arrangement so a reopened // project comes back in the saved mode. setState runs before the host queries bus info, so // seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent. { std::lock_guard lock(channelModeMutex_); channelMode_ = cs.channelMode; } applyOutputArrangement(cs.channelMode); // 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; } // 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(); state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode { 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_; } 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::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::applyOutputArrangement(ChannelMode mode) { // Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo / // getBusArrangement report the right channel count. The default getBusArrangement (from the // base) reads back exactly what we store here. No re-negotiation — the caller drives that. BusList* outs = getBusList(kAudio, kOutput); if (!outs || outs->empty()) return; if (auto* bus = FCast(outs->at(0))) { bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono); } } void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { { std::lock_guard lock(channelModeMutex_); if (channelMode_ == mode) return; // no-op: don't churn the bus / re-negotiate channelMode_ = mode; } // The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's // routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode // is driven from the editor, so this is safe. Then reload so the next block decodes the new // channel count into the LoadedInstrument (off-thread, RT path untouched). applyOutputArrangement(mode); if (componentHandler) componentHandler->restartComponent(kIoChanged); reloadFromBank(); } tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( SpeakerArrangement* inputs, int32 numIns, SpeakerArrangement* outputs, int32 numOuts) { // The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio // input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only // when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but // KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal // keeps a valid arrangement of its own). getBusArrangement then still reports the mode's // channel count, so the host adapts its routing to us rather than forcing our channel count. if (numIns < 0 || numOuts < 0) return kInvalidArgument; if (numIns > 0) return kResultFalse; // no audio input bus to arrange const SpeakerArrangement want = channelMode() == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono; applyOutputArrangement(channelMode()); // keep the bus pinned to the mode's arrangement if (numOuts == 1 && outputs && outputs[0] == want) 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. const 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) { 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): CC 123 (All Notes Off) / CC 120 (All // Sound Off) reset the engine — clear the mono held stack and release every // voice, live AND drain, engine AND preview card — so a phantom held-stack // entry left by a lost note-off can never be resurrected by the mono fallback // and sustain forever. 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). allNotesOff / // releaseAll are RT-safe (no allocation, bounded scans). const auto cc = static_cast(e.midiCCOut.controlNumber); if (cc == kCtrlAllNotesOff || cc == kCtrlAllSoundsOff) { 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; 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)); } // 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)); } 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