S7: stereo channel mode — core channel dimension, v4 mode state, VST3 bus negotiation, editor toggle

Per-instance mono|stereo (default mono, byte-identical). Stereo grows SampleData a 2nd
channel + a per-channel VoiceEngine render; decodeChannels applies the cross-mode policy;
setBusArrangements pins the mode's arrangement and restartComponent(kIoChanged) re-negotiates.
This commit is contained in:
2026-07-26 21:26:21 -04:00
parent ee82b50fb7
commit 4a3551b3b9
11 changed files with 727 additions and 85 deletions
+120 -24
View File
@@ -11,9 +11,12 @@
#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/vstspeaker.h"
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "ext_keys.h" // kProjExtBanksKey (shared wire contract)
#include "reasampler_editor.h"
@@ -63,12 +66,15 @@ std::vector<std::uint8_t> readFileBytes(const std::string& path) {
}
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
// I/O — off-thread only), and downmix to the core's mono contract. 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.
// 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) {
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);
@@ -76,11 +82,9 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
if (mono.empty()) return std::nullopt;
DecodedZonePcm out;
out.monoFrames = std::move(mono);
out.sampleRate = static_cast<int>(layout.sampleRate);
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(layout.sampleRate));
if (out.monoFrames.empty()) return std::nullopt;
return out;
}
@@ -117,10 +121,15 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
// instrument still loads, it just has no live bank to play.
bridge_.connect(context);
// Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio
// output, no audio input. This is the standard VSTi arrangement.
// 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);
addAudioOutput(STR16("Stereo Out"), SpeakerArr::kStereo);
const ChannelMode mode = channelMode();
addAudioOutput(STR16("Audio Out"),
mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono);
return kResultOk;
}
@@ -175,6 +184,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
const ComponentState cs = deserializeComponentState(bytes);
setSelectedSampleId(cs.selectionId);
setPerformanceMap(cs.map);
// 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);
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadFromBank();
return kResultOk;
@@ -190,6 +207,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
ComponentState state_out;
state_out.selectionId = selectedSampleId();
state_out.map = performanceMap();
state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode
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()),
@@ -219,6 +237,57 @@ void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
performanceMap_ = map;
}
ChannelMode ReaSamplerProcessor::channelMode() {
std::lock_guard<std::mutex> lock(channelModeMutex_);
return channelMode_;
}
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
@@ -234,6 +303,9 @@ std::string ReaSamplerProcessor::reloadFromBank() {
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();
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
@@ -257,7 +329,7 @@ std::string ReaSamplerProcessor::reloadFromBank() {
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, rz.relativePath);
decodeRelative(projectDir, rz.relativePath, mode);
if (!pcm) continue; // unreadable WAV -> drop this zone
kept.push_back(rz);
decoded.push_back(std::move(*pcm));
@@ -279,10 +351,11 @@ std::string ReaSamplerProcessor::reloadFromBank() {
selectSample(*banksJson, selectedSampleId());
if (sel) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath);
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
sel->rootNote, sel->loop);
sel->rootNote, sel->loop,
std::move(pcm->framesR));
haveKeymap = true;
resolvedId = selectedSampleId(); // the concrete pick that resolved
}
@@ -378,25 +451,48 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
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.
// 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;
if (ch0) {
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.
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));
}
// 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));
}
// Block peak (mono, pre-replicate) for the embedded strip's level indicator. A
// single scan of ch0 + one relaxed atomic store — RT-safe (no alloc/IO/lock). The
// UI thread reads it via embedActivityLevel(); a plain store is sufficient because
// the readout is advisory (no ordering dependency on other state).
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);
// 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];