926 lines
48 KiB
C++
926 lines
48 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 "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;
|
|
}
|
|
// 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_;
|
|
}
|
|
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::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): CC 123 (All Notes Off) / CC 120 (All
|
|
// Sound Off) reset the engine — clear the mono held stack and release every
|
|
// voice, live AND drain, engine AND preview card — so a phantom held-stack
|
|
// entry left by a lost note-off can never be resurrected by the mono fallback
|
|
// and sustain forever. REAPER delivers raw input MIDI CC to a VST3 instrument
|
|
// as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism — the type
|
|
// is nominally an output event; DAW-verify, see handoff). allNotesOff /
|
|
// releaseAll are RT-safe (no allocation, bounded scans).
|
|
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
|
|
if (cc == kCtrlAllNotesOff || cc == kCtrlAllSoundsOff) {
|
|
if (inst) {
|
|
inst->engine.allNotesOff();
|
|
inst->preview.releaseAll();
|
|
}
|
|
if (drain) {
|
|
drain->engine.allNotesOff();
|
|
drain->preview.releaseAll();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
|
|
// atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last
|
|
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
|
|
// Phase S: preview note-on/off drive the dedicated PREVIEW CARD — a single voice structurally
|
|
// OUTSIDE the MIDI pool, so a full pool can never drop a preview and a preview can never
|
|
// steal a playing MIDI voice (the FA1-review isolation fix). Host MIDI routes ONLY to the
|
|
// engine (above); the card is summed alongside it in the render below.
|
|
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
|
|
// is loaded does not re-fire stale on the next instrument load.
|
|
{
|
|
const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
|
|
const std::uint16_t onSeq = static_cast<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));
|
|
}
|
|
// 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));
|
|
}
|
|
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
|