// reasampler_processor.cpp — see reasampler_processor.h. #include "reasampler_processor.h" #include #include #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" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) #include "sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) #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; // pS-usage: mint a fresh per-instance publish identity — 32 lowercase hex chars from the // OS entropy source. Uniqueness (not cryptographic strength) is the requirement: two // instances sharing a key is the copy-collision planUsagePublish resolves fail-safe // anyway; the mint just makes accidental collision vanishingly unlikely. Off-thread only. std::string mintUsageInstanceGuid() { std::random_device rd; std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); std::uniform_int_distribution dist; char buf[33] = {0}; std::snprintf(buf, sizeof(buf), "%016llx%016llx", static_cast(dist(gen)), static_cast(dist(gen))); return std::string(buf); } // 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) { // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED // sample refs — it needs no bank read, so it plays regardless of whether the // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). // // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic // refreshRefsFromBank copies the refs in when the bank blob is readable by // activation time, so an upgraded project plays on load without the instrument // ever being opened (and the next save is self-contained). Residual load-order // race, DAW-verifiable only: if the host activates this instance BEFORE the // project's ext-state lines parse, the lift misses here and — with no editor open — // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a // pre-v10 instrument once after upgrading if it restores silent. reloadInstrument(); } 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 (reloadInstrument 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 reloadInstrument). // 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 + reloadInstrument 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); // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the // reload so it decodes straight from them — no bank read required to play. A pre-v10 // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), // after which the next save is self-contained. { std::lock_guard lock(refsMutex_); sampleRefs_ = cs.sampleRefs; } // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — // minted on first publish). lastPublishedUsageWire_ resets: a restored blob is a NEW // LIFETIME for the copy-collision analysis (planUsagePublish must compare the key's // current value against what THIS incarnation wrote, not a previous one's writes). { std::lock_guard lock(usageMutex_); instanceGuid_ = cs.instanceGuid; lastPublishedUsageWire_.clear(); } // A new blob is new facts: a staleness proof latched against the PREVIOUS state does // not carry over (#A — the legacy lift gets one fresh run per restored state). legacyLiftConcluded_.store(false, std::memory_order_relaxed); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadInstrument(); 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) // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to // decode + play with no extension present. Filtered (on the snapshot copy, the member is // untouched) to exactly what the instance currently plays, so the table cannot grow with // browsing history. state_out.sampleRefs = sampleRefs(); retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId, state_out.map)); // pS-usage: persist the publish identity (v11) so the instance's usage key is // stable across sessions (records do not proliferate per reopen). { std::lock_guard lock(usageMutex_); state_out.instanceGuid = instanceGuid_; } 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; } SampleRefs ReaSamplerProcessor::sampleRefs() { std::lock_guard lock(refsMutex_); return sampleRefs_; } 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. reloadInstrument(); } 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::reloadInstrument() { // 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. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of // truth for what to decode. The live bank blob, WHEN readable, is folded into the // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in // mechanism and the S9 recapture sync in one — but its absence changes NOTHING // below: a project restored before the extension's PROJEXTSTATE parses (or with // the extension absent entirely) resolves + plays from the persisted refs. The // project dir comes from REAPER itself (EnumProjects), not from the extension. const std::string selId = selectedSampleId(); const PerformanceMap map = performanceMap(); const std::vector ids = referencedSampleIds(selId, map); SampleRefs refs; { std::optional banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey); std::lock_guard rl(refsMutex_); if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); // The LOAD path never prunes the owned table: dropping entries here on a transient // bank miss could destroy the owned intrinsics of the previous selection — the ONE // copy that survives with the extension absent. Entries for de-referenced ids stay // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, // where getState filters its snapshot via retainRefs to what the instance plays. refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) } 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; Keymap km; bool haveKeymap = false; // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its // zones against the OWNED refs (an id with no ref drops cleanly), decode each // zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays // its sample repitched from its effective root note (override > ref intrinsic > // C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped // (not the whole map): the defined no-play, no crash, no retry loop. if (!map.empty()) { const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, 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/missing 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, resolved // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a // selection with no ref) resolves to nothing, 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) { if (const SelectedSample* sel = findRef(refs, selId)) { // GA auto-default: channelModeFor computes the mode from the loaded capture's // REQUESTED channel count (always 2 for extension captures; mono only for // ingest-imported mono files). An unknown count (0) or explicit user choice // returns the current mode unchanged. Decode-only: the output bus is fixed // stereo, so no bus work follows a flip. { std::lock_guard cm(channelModeMutex_); channelMode_ = channelModeFor(sel->channelCount, channelMode_, channelModeExplicit_); 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 = selId; // 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 ref / 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)); // 5. pS-usage: publish this instance's held captures so the extension's prune can // never reclaim them (see publishUsage). AFTER the instrument swap, still off the // audio thread and under reloadMutex_. Publishes regardless of decode success: // the holds are the refs the instance RETAINS (its play-set), not what decoded — // a transiently unreadable WAV must stay protected. publishUsage(refs, ids); return resolvedId; } void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, const std::vector& ids) { if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do UsageRecord mine; mine.trackGuid = bridge_.currentTrackGuid(); for (const std::string& id : ids) { if (const SelectedSample* ref = findRef(refs, id)) { if (!ref->relativePath.empty()) { mine.holds.push_back(UsageHold{id, ref->relativePath}); } } } std::lock_guard lock(usageMutex_); // A never-published instance with nothing held writes nothing — no key litter for // fresh/empty instances. Once an identity exists, empties DO publish (they release // holds the prune would otherwise keep protecting). if (instanceGuid_.empty() && mine.holds.empty()) return; if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); const std::optional existing = bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); const UsagePublishPlan plan = planUsagePublish(existing, lastPublishedUsageWire_, mine); if (plan.remint) { // This state was cloned onto another track (FX copy / track duplication): take a // fresh identity and leave the original's record untouched. The abandoned old // identity's record dies by the extension's liveness rule when its track no // longer hosts an instance. getState persists the new guid on the next save. instanceGuid_ = mintUsageInstanceGuid(); } else if (plan.skipWrite) { return; // byte-identical to what this lifetime already wrote — idle tick } if (bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire)) { lastPublishedUsageWire_ = plan.wire; } } void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by // reloadInstrument 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 // 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 reloadInstrument (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 reloadInstrument): 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()); } bool ReaSamplerProcessor::legacyLiftShouldRun() { // #A terminating guard for the pre-v10 legacy lift. The caller has already established // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before // paying for a full reload. Once concluded, the steady state is this one relaxed load — // no bank read, no parse, no reload churn. if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; const LegacyLiftDecision decision = legacyLiftDecision( bridge_.readReasamplerExtState(kProjExtBanksKey), referencedSampleIds(selectedSampleId(), performanceMap())); if (decision == LegacyLiftDecision::Stale) { // Provably stale (the bank parses and knows none of the referenced ids): give up // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, // and the genChanged reload refreshes the refs without consulting this latch. legacyLiftConcluded_.store(true, std::memory_order_relaxed); return false; } return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) } 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. reloadInstrument 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 instrument from its OWNED refs (pS), 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; // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had // nothing to decode unless the bank happened to be readable already. Reload on this // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs // when readable, after which the table is non-empty and this never fires again (the // next save is then self-contained). A deliberately-empty instance has no intent and // never churns; a bank that is not readable YET retries a cheap null publish on the // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob // PARSES and no referenced id resolves in it, the ids are provably stale — there is // nothing to lift, so the lift concludes permanently instead of churning a full bank // read + reload every tick forever. This is a MIGRATION convenience for old projects, // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). bool legacyLift = false; if (!genChanged && !result.applied && sampleRefs().empty()) { const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); legacyLift = hasIntent && legacyLiftShouldRun(); } if (genChanged || result.applied || legacyLift) { reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank // view. A legacy lift counts only when it actually landed an instrument (otherwise // every retry tick would churn the editor's caches for nothing). result.reloaded = genChanged || (legacyLift && live_.load(std::memory_order_acquire) != nullptr); } 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 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. A ringing // preview note is a real engine voice since the PreviewCard retirement, so // the panics cover it with no separate routing. allNotesOff / allSoundsOff // are RT-safe (no allocation, bounded scans). const auto cc = static_cast(e.midiCCOut.controlNumber); if (cc == kCtrlAllSoundsOff) { if (inst) inst->engine.allSoundsOff(); if (drain) drain->engine.allSoundsOff(); } else if (cc == kCtrlAllNotesOff) { if (inst) inst->engine.allNotesOff(); if (drain) drain->engine.allNotesOff(); } } } } // 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. // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real // voice: it counts against the voice count, can steal / be stolen, and respects // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's // isolation). The editor posts the root note, so it plays at unity. // 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->engine.noteOn(note, vel); } } } { 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_) { // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending // while nothing was loaded would otherwise survive until a (heal) reload lands // and release the NEXT preview press in the same block. previewOffConsumed_ = offSeq; // Route the preview note-off to BOTH engines (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 voice now draining, not just the (fresh) live one. // NOTE: preview shares the host-MIDI note space — noteOff releases the newest // voice at that pitch, so a preview release can release a host-held note at // the same pitch (inherent to routing preview through the real note path). if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); if (drain) drain->engine.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)); if (drain) drain->engine.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)); if (drain) drain->engine.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