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:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user