Q-W2v: split VST god-modules — editor 8 face-axis TUs (+pure layout hoist), processor 3 TUs, component_state_io codec split (extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, 61/61 green

This commit is contained in:
2026-07-29 10:56:09 -04:00
parent 9d5783453c
commit ea86f540b8
37 changed files with 6202 additions and 5352 deletions
+311
View File
@@ -0,0 +1,311 @@
// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState /
// getState against the component_state_io codec) and its UI-thread parameter
// accessors/setters (selection, performance map, channel mode, preview velocity,
// voice-system params, master gain, preview-note mailbox posts). Split out of
// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio
// thread (UI / host load-save); the setters hand work to the reload family
// (processor_reload.cpp) or store atomics process() picks up at block start.
#include "shell/instrument/reasampler_processor.h"
#include <cstdint>
#include <mutex>
#include <vector>
#include "pluginterfaces/base/ibstream.h"
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split)
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
using namespace Steinberg;
using namespace Steinberg::Vst;
namespace reasampler::vst {
using namespace instrument::map; // the codec + resolution vocabulary this TU marshals
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 reloadInstrument).
// 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 + reloadInstrument 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 + 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;
}
// 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;
}
// FB1: restore the post-mixer master gain (v8; older blobs lift to unity in
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
// it up at the next block start.
setMasterGainLinear(cs.masterGainLinear);
// pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the
// reload so it decodes straight from them — no bank read required to play. A pre-v10
// blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob
// becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift),
// after which the next save is self-contained.
{
std::lock_guard<std::mutex> lock(refsMutex_);
sampleRefs_ = cs.sampleRefs;
}
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
// minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME
// for the copy-collision analysis (the fresh nonce means this incarnation can never
// be mistaken for the previous one's writes — or for a copy-sibling's).
{
std::lock_guard<std::mutex> lock(usageMutex_);
instanceGuid_ = cs.instanceGuid;
usageNonce_.clear();
}
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
// not carry over (#A — the legacy lift gets one fresh run per restored state).
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadInstrument();
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();
{
// 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
}
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_;
}
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
// pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to
// decode + play with no extension present. Filtered (on the snapshot copy, the member is
// untouched) to exactly what the instance currently plays, so the table cannot grow with
// browsing history.
state_out.sampleRefs = sampleRefs();
retainRefs(state_out.sampleRefs,
referencedSampleIds(state_out.selectionId, state_out.map));
// pS-usage: persist the publish identity (v11) so the instance's usage key is
// stable across sessions (records do not proliferate per reopen).
{
std::lock_guard<std::mutex> lock(usageMutex_);
state_out.instanceGuid = instanceGuid_;
}
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;
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_;
}
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::setMasterGainLinear(double linear) {
// Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap =
// +24 dB). One relaxed atomic store — the audio thread reads it at the next block start;
// no rebuild, no lock (a post-sum output trim is not a keymap fact).
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
const double maxLin = masterGainMaxLinear();
if (linear > maxLin) linear = maxLin;
masterGain_.store(static_cast<float>(linear), std::memory_order_relaxed);
}
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::setChannelMode(ChannelMode mode) {
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
// 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 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.
reloadInstrument();
}
} // namespace reasampler::vst