S4 Tier 0: the bank plays — VST3 marshals MIDI to the S3 core, reads the live bank + resolves WAV the M4 way, mono downmix, lock-free load handoff, LICE sample-pick

This commit is contained in:
2026-07-26 16:43:04 -04:00
parent b0fc052113
commit 0cde457224
24 changed files with 1239 additions and 302 deletions
+225 -19
View File
@@ -2,16 +2,64 @@
#include "reasampler_processor.h"
#include <cstdint>
#include <fstream>
#include <vector>
#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<std::int64_t>(kAttackSeconds * sr);
p.decayFrames = static_cast<std::int64_t>(kDecaySeconds * sr);
p.sustainLevel = kSustainLevel;
p.releaseFrames = static_cast<std::int64_t>(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<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;
}
} // 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.
@@ -23,7 +71,7 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
if (result != kResultOk) return result;
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
// instrument still loads, the editor just shows "no bridge".
// 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
@@ -35,46 +83,204 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
}
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<std::mutex> lock(reloadMutex_);
delete live_.exchange(nullptr);
graveyard_.clear();
return SingleComponentEffect::terminate();
}
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) {
// Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here
// against the setupProcessing block size.
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_);
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::process(ProcessData& data) {
// Silent skeleton: emit silence on the output bus so the instrument runs cleanly in
// REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio.
if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) {
AudioBusBuffers& out = data.outputs[0];
for (int32 ch = 0; ch < out.numChannels; ++ch) {
if (data.symbolicSampleSize == kSample32) {
if (float* buf = out.channelBuffers32[ch]) {
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.f;
}
} else if (data.symbolicSampleSize == kSample64) {
if (double* buf = out.channelBuffers64[ch]) {
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0;
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<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);
if (got < static_cast<int32>(sizeof(chunk))) break;
}
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<std::uint8_t> bytes = serializeSelection(selectedSampleId());
if (!bytes.empty()) {
state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
}
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;
}
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_);
// 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();
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
if (banksJson) {
// 2. Pick the sample (shared bank_book JSON parse — NOT a second parser).
std::optional<SelectedSample> 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<std::uint8_t> bytes = readFileBytes(abs);
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
const std::size_t frames = layout.frameCount();
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, frames);
std::vector<AudioSample> mono =
downmixToMono(interleaved, layout.channelCount);
if (!mono.empty()) {
Keymap km = buildTier0Keymap(
std::move(mono),
static_cast<int>(layout.sampleRate), sel->rootNote,
sel->loop);
built = std::make_unique<LoadedInstrument>(
std::move(km), kMaxVoices, tier0Adsr(sampleRate_));
// Record which id actually resolved so a first-sample fallback
// (empty stored id) becomes the concrete selection.
resolvedId = selectedSampleId();
}
}
}
}
// Flag output silence so the host can optimize (nothing plays yet).
}
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
// graveyard (process may still be reading it this block — it is reclaimed only when
// process is stopped, in setActive(false)/terminate). 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.
LoadedInstrument* prev = live_.exchange(built.release());
if (prev) graveyard_.emplace_back(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).
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
// 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<int>(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<std::size_t>(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(&bridge_);
return new ReaSamplerEditor(this);
}
return nullptr;
}