fix(vst): pin output bus to stereo (mode is decode-only) killing the hard-right pan; auto-default channel mode from the loaded capture (state v9)

This commit is contained in:
2026-07-28 06:19:28 -04:00
parent 104a25f390
commit 3e6629a867
7 changed files with 212 additions and 74 deletions
+51 -47
View File
@@ -11,13 +11,10 @@
#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)
@@ -121,14 +118,18 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
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.)
// audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of
// the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders
// dual-mono through the stereo bus (both channels equal, centered), which is audibly
// identical to a mono bus but never asks the host to re-map a live instance's pins. The
// prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every
// mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The
// in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer
// write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's
// re-routing of the live instance's pins across the arrangement change. A fixed arrangement
// is the maximally-standard VSTi shape and removes that whole negotiation surface.
addEventInput(STR16("MIDI In"), 16);
const ChannelMode mode = channelMode();
addAudioOutput(STR16("Audio Out"),
mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono);
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
return kResultOk;
}
@@ -209,14 +210,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
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.
// Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see
// initialize) — the mode only governs how the reload below decodes, so no bus work here.
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
channelMode_ = cs.channelMode;
channelModeExplicit_ = cs.channelModeExplicit;
}
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.
{
@@ -251,7 +251,12 @@ 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
{
// S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9).
std::lock_guard<std::mutex> lock(channelModeMutex_);
state_out.channelMode = channelMode_;
state_out.channelModeExplicit = channelModeExplicit_;
}
{
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
@@ -397,49 +402,32 @@ void ReaSamplerProcessor::previewNoteOff(int note) {
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
// The editor toggle is a DELIBERATE choice either way: latch explicit even on a
// same-mode click (the user confirmed the mode; the GA auto-default stops fighting it).
channelModeExplicit_ = true;
if (channelMode_ == mode) return; // no decode change: don't churn a reload
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);
// The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
// restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
// (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
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.
// ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a
// decode policy, never a bus fact). We take NO audio input, so any inputs are rejected.
// Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse)
// and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a
// proposal keeps a valid arrangement of its own) — the host adapts its routing to us.
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;
if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue;
return kResultFalse;
}
@@ -459,8 +447,9 @@ std::string ReaSamplerProcessor::reloadFromBank() {
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();
// Read once under its mutex, off the audio thread, before the decode loop. The single-
// capture branch below may auto-default it (GA) before its decode.
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;
@@ -516,6 +505,18 @@ std::string ReaSamplerProcessor::reloadFromBank() {
std::optional<SelectedSample> sel =
selectSample(*banksJson, selectedSampleId());
if (sel) {
// GA auto-default: while the channel mode is IMPLICIT (never user-toggled),
// follow the loaded capture's channel count — a stereo capture decodes (and
// shows) Stereo, a mono one Mono. An unknown count (0, an older bank entry)
// changes nothing; an explicit user choice is never fought. Decode-only: the
// output bus is fixed stereo, so no bus work follows a flip.
if (sel->channelCount > 0) {
const ChannelMode desired = sel->channelCount >= 2 ? ChannelMode::Stereo
: ChannelMode::Mono;
std::lock_guard<std::mutex> lock(channelModeMutex_);
if (!channelModeExplicit_) channelMode_ = desired;
mode = channelMode_;
}
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
@@ -887,6 +888,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// 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;
// The PLUG-IN owns output silenceFlags (VST3 contract). Claim non-silence on every rendered
// block — a stale host-side flag left unwritten could mute a channel downstream (GA).
out.silenceFlags = 0;
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.