// reasampler_processor.cpp — see reasampler_processor.h. #include "reasampler_processor.h" #include #include #include #include #include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" #include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/vstspeaker.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" #include "sample_map.h" // selectSample, downmixToMono, buildTier0Keymap, state (de)ser #include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) using namespace Steinberg; using namespace Steinberg::Vst; namespace reasampler::vst { namespace { // Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so // notes neither click on nor cut off abruptly; sustain at unity (velocity does the // dynamics), a short release for a natural tail. Times are in seconds, converted to // frames against the live sample rate at build time. constexpr double kAttackSeconds = 0.003; constexpr double kDecaySeconds = 0.0; constexpr double kSustainLevel = 1.0; constexpr double kReleaseSeconds = 0.060; constexpr std::size_t kMaxVoices = 16; AdsrParams tier0Adsr(double sampleRate) { const double sr = sampleRate > 0.0 ? sampleRate : 44100.0; AdsrParams p; p.attackFrames = static_cast(kAttackSeconds * sr); p.decayFrames = static_cast(kDecaySeconds * sr); p.sustainLevel = kSustainLevel; p.releaseFrames = static_cast(kReleaseSeconds * sr); return p; } // 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; } } // 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()); } 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 stereo audio // output, no audio input. This is the standard VSTi arrangement. addEventInput(STR16("MIDI In"), 16); addAudioOutput(STR16("Stereo Out"), SpeakerArr::kStereo); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::terminate() { // process() is not running at terminate. Free the live instrument and drain the // graveyard. Take the pointer out of the atomic first so nothing else races it. std::lock_guard lock(reloadMutex_); delete live_.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_); 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 selected sample id, 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); } setSelectedSampleId(deserializeSelection(bytes)); // Rebuild from the restored selection (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; const std::vector bytes = serializeSelection(selectedSampleId()); 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; } 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(); std::string resolvedId; std::unique_ptr built; if (banksJson) { // 2. Pick the sample (shared bank_book JSON parse — NOT a second parser). std::optional sel = selectSample(*banksJson, selectedSampleId()); if (sel) { // 3. Resolve the project-relative WAV path the M4 way persist does, read + // decode it (file I/O off-thread), downmix to the core's mono contract. const std::string abs = resolveBankFile(projectDir, sel->relativePath); if (!abs.empty()) { const std::vector bytes = readFileBytes(abs); const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { const std::size_t frames = layout.frameCount(); std::vector interleaved = extractFloatFrames(bytes, layout, 0, frames); std::vector mono = downmixToMono(interleaved, layout.channelCount); if (!mono.empty()) { Keymap km = buildTier0Keymap( std::move(mono), static_cast(layout.sampleRate), sel->rootNote, sel->loop); built = std::make_unique( std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); // Record which id actually resolved so a first-sample fallback // (empty stored id) becomes the concrete selection. resolvedId = selectedSampleId(); } } } } } // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the // graveyard tagged with this generation (process may still be mid-block reading // it). A null `built` (no bank / unreadable WAV) installs silence. // `built` is heap-owned; release() hands ownership to the atomic, and the // exchanged pointer is re-owned by the graveyard. // // Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen // is the last generation process() published. process() publishes inst->installedAt // (not a re-read of reloadGeneration_), so seen == D means process holds the // instrument installed at gen D. An entry with displacedAt == D was displaced by // reload D, which installed that very successor — process cannot be holding the // displaced entry. The pruning condition is therefore <= (see header for the full // 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 GraveyardEntry& e) { return e.displacedAt <= seen; }), graveyard_.end()); LoadedInstrument* prev = live_.exchange(built.release()); if (prev) graveyard_.push_back({gen, std::unique_ptr(prev)}); return resolvedId; } tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the // whole block (a single atomic acquire), then publish inst->installedAt so the off- // thread graveyard pruner knows exactly which generation this block is holding. // // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an // ordering race: reading reloadGeneration_ after live_ could observe a generation // newer than the pointer 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, so it is always <= the generation of any // instrument that could have been loaded after our acquire above. LoadedInstrument* inst = live_.load(std::memory_order_acquire); const std::uint64_t heldGen = inst ? inst->installedAt : 0; processGeneration_.store(heldGen, std::memory_order_release); // 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. if (inst && 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) { inst->engine.noteOff(e.noteOn.pitch); } else { inst->engine.noteOn(e.noteOn.pitch, vel); } } else if (e.type == Event::kNoteOffEvent) { inst->engine.noteOff(e.noteOff.pitch); } } } if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { 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) { 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 mono into channel 0's buffer, then replicate to the other channels (the // core is mono-per-sample). Clear channel 0 first (render ADDS), then mix. float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; if (ch0) { for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; if (inst) { inst->engine.render(ch0, static_cast(frames)); } // Duplicate the mono render across the remaining output channels. 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 we clear the flag so a ringing voice is not skipped. out.silenceFlags = inst ? 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