Files
reasampler/src/vst/reasampler_processor.cpp
T

972 lines
50 KiB
C++

// reasampler_processor.cpp — see reasampler_processor.h.
#include "reasampler_processor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <optional>
#include <utility>
#include <vector>
#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 "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
#include "reasampler_editor.h"
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
using namespace Steinberg;
using namespace Steinberg::Vst;
namespace reasampler::vst {
namespace {
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the
// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
constexpr std::size_t kPreserveVoiceCap = 8;
// 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<std::uint8_t> readFileBytes(const std::string& path) {
std::vector<std::uint8_t> 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<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(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<DecodedZonePcm> 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<std::uint8_t> bytes = readFileBytes(abs);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(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<IAudioProcessor*>(new ReaSamplerProcessor());
}
// Out-of-line so unique_ptr<ReaSamplerEmbed> 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<ReaSamplerEmbed>(this);
embed_->addRef();
*obj = static_cast<IReaperUIEmbedInterface*>(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<std::mutex> 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<std::mutex> 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<std::uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(voiceParamsMutex_);
voiceCount_ = cs.voiceCount;
voiceMode_ = cs.voiceMode;
monoTrigger_ = cs.monoTrigger;
}
// FB1: restore the post-mixer master gain (v8; older blobs lift to unity in
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
// it up at the next block start.
setMasterGainLinear(cs.masterGainLinear);
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadFromBank();
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
if (!state) return kResultFalse;
// Persist the full instance state (v3, S10): the single-capture selection id AND the
// opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
// bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
// and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
// sample #1.
ComponentState state_out;
state_out.selectionId = selectedSampleId();
state_out.map = performanceMap();
state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode
{
std::lock_guard<std::mutex> 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<std::mutex> lock(voiceParamsMutex_);
state_out.voiceCount = voiceCount_;
state_out.voiceMode = voiceMode_;
state_out.monoTrigger = monoTrigger_;
}
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
if (wr != kResultOk) return wr;
}
return kResultOk;
}
std::string ReaSamplerProcessor::selectedSampleId() {
std::lock_guard<std::mutex> lock(selectionMutex_);
return selectedSampleId_;
}
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
std::lock_guard<std::mutex> lock(selectionMutex_);
selectedSampleId_ = id;
}
PerformanceMap ReaSamplerProcessor::performanceMap() {
std::lock_guard<std::mutex> lock(performanceMutex_);
return performanceMap_;
}
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
std::lock_guard<std::mutex> lock(performanceMutex_);
performanceMap_ = map;
}
ChannelMode ReaSamplerProcessor::channelMode() {
std::lock_guard<std::mutex> lock(channelModeMutex_);
return channelMode_;
}
std::uint8_t ReaSamplerProcessor::previewVelocity() {
std::lock_guard<std::mutex> 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<std::mutex> lock(previewMutex_);
previewVelocity_ = velocity;
}
int ReaSamplerProcessor::voiceCount() {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(voiceParamsMutex_);
return voiceMode_;
}
void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) {
{
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
if (voiceMode_ == mode) return;
voiceMode_ = mode;
}
rebuildVoiceEngine();
}
MonoTrigger ReaSamplerProcessor::monoTrigger() {
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
return monoTrigger_;
}
void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
{
std::lock_guard<std::mutex> 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<float>(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<std::uint32_t>(seq) << 16) |
(static_cast<std::uint32_t>(vel) << 8) |
static_cast<std::uint32_t>(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<std::uint32_t>(seq) << 16) |
static_cast<std::uint32_t>(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<AudioBus>(outs->at(0))) {
bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo
: SpeakerArr::kMono);
}
}
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::string> 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<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
std::string resolvedId;
std::unique_ptr<LoadedInstrument> 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<DecodedZonePcm> decoded;
std::vector<ResolvedZone> kept;
decoded.reserve(resolved.zones.size());
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> 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<SelectedSample> sel =
selectSample(*banksJson, selectedSampleId());
if (sel) {
std::optional<DecodedZonePcm> 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<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(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<LoadedInstrument> 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<LoadedInstrument>& 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<LoadedInstrument>(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<std::mutex> 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<std::mutex> 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<std::int64_t>(
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<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(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<std::mutex> 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<LoadedInstrument>(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<LoadedInstrument>& 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<AssignmentRequest> 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<std::mutex> 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<int>(e.noteOn.velocity * 127.0f + 0.5f);
if (vel <= 0) {
if (inst) inst->engine.noteOff(e.noteOn.pitch);
if (drain) drain->engine.noteOff(e.noteOn.pitch);
} else if (inst) {
inst->engine.noteOn(e.noteOn.pitch, vel);
}
} else if (e.type == Event::kNoteOffEvent) {
if (inst) inst->engine.noteOff(e.noteOff.pitch);
if (drain) drain->engine.noteOff(e.noteOff.pitch);
} else if (e.type == Event::kLegacyMIDICCOutEvent) {
// PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a
// VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism
// — the type is nominally an output event; DAW-verify, see handoff).
// CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR
// release tail; Trigger one-shots play through their bounded play length.
// CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless
// of play mode, including Trigger one-shots that ignore CC 123. This is the
// true "panic" for a ringing one-shot (e.g. a full-length capture).
// Both clear the mono held stack. Both apply to live AND drain, engine AND preview.
// allNotesOff / allSoundsOff / releaseAll / hardStop are RT-safe (no allocation,
// bounded scans).
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
if (cc == kCtrlAllSoundsOff) {
if (inst) {
inst->engine.allSoundsOff();
inst->preview.hardStop();
}
if (drain) {
drain->engine.allSoundsOff();
drain->preview.hardStop();
}
} else if (cc == kCtrlAllNotesOff) {
if (inst) {
inst->engine.allNotesOff();
inst->preview.releaseAll();
}
if (drain) {
drain->engine.allNotesOff();
drain->preview.releaseAll();
}
}
}
}
}
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
// atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
// Phase S: preview note-on/off drive the dedicated PREVIEW CARD — a single voice structurally
// OUTSIDE the MIDI pool, so a full pool can never drop a preview and a preview can never
// steal a playing MIDI voice (the FA1-review isolation fix). Host MIDI routes ONLY to the
// engine (above); the card is summed alongside it in the render below.
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
// is loaded does not re-fire stale on the next instrument load.
{
const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
if (onSeq != 0 && onSeq != previewOnConsumed_) {
previewOnConsumed_ = onSeq;
if (inst) {
const int vel = static_cast<int>((on >> 8) & 0xFF);
const int note = static_cast<int>(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<std::uint16_t>(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<int>(off & 0xFF));
if (drain) drain->preview.noteOff(static_cast<int>(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<std::size_t>(frames));
inst->preview.render(ch0, ch1, static_cast<std::size_t>(frames));
}
if (drain) {
drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
drain->preview.render(ch0, ch1, static_cast<std::size_t>(frames));
}
// FB1 post-mixer master gain: ONE relaxed load per block, applied AFTER the voice sum
// (engine + drain + preview) and BEFORE the extra-channel mirror + peak, so the mirror
// and the level indicator both see the actual output. A cheap multiply — no per-voice
// cost, no alloc, no lock (RT discipline).
{
const float g = masterGain_.load(std::memory_order_relaxed);
if (g != 1.f) {
for (int32 i = 0; i < frames; ++i) { ch0[i] *= g; ch1[i] *= g; }
}
}
// 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<std::size_t>(frames));
inst->preview.render(ch0, static_cast<std::size_t>(frames));
}
if (drain) {
drain->engine.render(ch0, static_cast<std::size_t>(frames));
drain->preview.render(ch0, static_cast<std::size_t>(frames));
}
// FB1 post-mixer master gain (mono path) — same contract as the stereo branch above:
// post-sum, pre-peak/replicate, one relaxed load, RT-safe.
{
const float g = masterGain_.load(std::memory_order_relaxed);
if (g != 1.f) {
for (int32 i = 0; i < frames; ++i) ch0[i] *= g;
}
}
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