// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState // against the component_state_io codec) and its UI-thread parameter accessors/setters // (selection, the one parameter set, channel mode, preview velocity, voice-system params, // master gain, preview-note mailbox posts). Everything here runs off the audio thread; // 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 #include #include #include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kLatencyChanged #include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp) #include "core/instrument/map/component_state_io.h" // the ComponentState codec #include "core/instrument/map/sample_map.h" // retainRefs / referencedSampleIds using namespace Steinberg; using namespace Steinberg::Vst; namespace reasampler::vst { using namespace instrument::map; // the codec + resolution vocabulary this TU marshals using instrument::engine::masterGainMaxLinear; // taper ceiling tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { if (!state) return kResultFalse; // The blob is small; read it in one shot into a growable buffer. std::vector 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 is {loaded capture id, one parameter set}. deserializeComponentState // lifts older blobs cleanly — including the retired zone payloads, which adopt zone // one's capture into cs.selectionId. sampleRate_ is the real host rate here (REAPER // calls setupProcessing before setState on load), which the legacy v3 payload's // frames->seconds conversion needs. const ComponentState cs = deserializeComponentState(bytes, sampleRate_); // A LOAD is not an edit: the SDK is explicit that a controller must never pass a restored // value back to the host through IComponentHandler. The push into the controller happens // once at the tail instead, through syncParamsFromModel. paramNotifySuppressed_ = true; setSelectedSampleId(cs.selectionId); setInstrumentParams(cs.params); // Restore the last-consumed assignment generation so a re-open does not re-apply a // stale assign_request. { std::lock_guard lock(assignMarkerMutex_); lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; } // The output bus is fixed stereo (see initialize) — the mode only governs decode below. { std::lock_guard lock(channelModeMutex_); channelMode_ = cs.channelMode; channelModeExplicit_ = cs.channelModeExplicit; } { std::lock_guard lock(previewMutex_); previewVelocity_ = cs.previewVelocity; } // Restore before the reload so the rebuilt engine is born with the saved polyphony/mode. { std::lock_guard lock(voiceParamsMutex_); voiceCount_ = cs.voiceCount; voiceMode_ = cs.voiceMode; monoTrigger_ = cs.monoTrigger; } setMasterGainLinear(cs.masterGainLinear); // Restore the instance-owned sample refs before the reload so it decodes straight from // them — no bank read required. A pre-v10 blob lifts to an empty table; the reload // resolves nothing until the bank blob becomes readable (opportunistic refresh, or // pollBankSync's legacy lift), after which the next save is self-contained. { std::lock_guard lock(refsMutex_); sampleRefs_ = cs.sampleRefs; } // Restore the publish identity (pre-v11 lifts to empty, minted on first publish). // usageNonce_ resets: a restored blob is a new lifetime, so this incarnation can never // be mistaken for the previous one's writes or a copy-sibling's. { std::lock_guard lock(usageMutex_); instanceGuid_ = cs.instanceGuid; usageNonce_.clear(); } // A new blob is new facts — the legacy lift gets one fresh run per restored state. legacyLiftConcluded_.store(false, std::memory_order_relaxed); reloadInstrument(); paramNotifySuppressed_ = false; // Every exposed parameter now reads the blob's value. Ordering against the host's first // parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one // model and one funnel per control, so whichever of the two writes last simply wins, and // the host's display follows the model either way. syncParamsFromModel(); // This caller has no editor to flush for it. At the TAIL on purpose: a host that services the // restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads // against the refs above, which are only fully restored once this function has run to here. flushLatencyRestart(); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; // Persists the full instance state — never written to the "reasampler" bank ext-state. // No pick serializes to {"", default params}, restoring as silence (never auto-playing // sample #1). ComponentState state_out; state_out.selectionId = selectedSampleId(); state_out.params = instrumentParams(); { std::lock_guard lock(channelModeMutex_); state_out.channelMode = channelMode_; state_out.channelModeExplicit = channelModeExplicit_; } { std::lock_guard lock(assignMarkerMutex_); state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; } state_out.previewVelocity = previewVelocity(); { std::lock_guard lock(voiceParamsMutex_); state_out.voiceCount = voiceCount_; state_out.voiceMode = voiceMode_; state_out.monoTrigger = monoTrigger_; } state_out.masterGainLinear = masterGainLinear(); // Persist the owned sample refs — the saved blob decodes + plays with no extension // present. Filtered (snapshot copy only) to 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)); // Persist the publish identity so the usage key is stable across sessions. { std::lock_guard lock(usageMutex_); state_out.instanceGuid = instanceGuid_; } const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), static_cast(bytes.size()), nullptr); if (wr != kResultOk) return wr; } return kResultOk; } std::string ReaSamplerProcessor::selectedSampleId() { std::lock_guard lock(selectionMutex_); return selectedSampleId_; } void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { std::lock_guard lock(selectionMutex_); selectedSampleId_ = id; } InstrumentParams ReaSamplerProcessor::instrumentParams() { std::lock_guard lock(paramsMutex_); return params_; } void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { InstrumentParams before; { std::lock_guard lock(paramsMutex_); before = params_; params_ = params; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — // funnels through here, so mirroring the limiter flag at this one point is what keeps the // audio thread's copy and the latency report from ever lagging what is persisted. The MIRROR // is inline, because that is the sound the user clicked for; the host notification is not, // because this funnel is reachable from inside a mouse handler and restartComponent is not // safe there (see this directory's CLAUDE.md). publishLimiterEnabled(params.limiterEnabled); // Armed AFTER the mirror, so getLatencySamples already answers the new value for the whole // window the arm stays outstanding. Compared against the last ANNOUNCED enable rather than // against the previous parameter set: off->on->off inside one tick ends at the latency the // host already knows, and a restart rebuilds the instance, so announcing a latency that // never changed is pure cost. Any number of changes before one flush still cost at most one // restart, and this store is the only one that raises OR lowers the arm. latencyRestartPending_.store( params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed), std::memory_order_release); // The host-notification obligation, at the same one funnel and for the same reason the // limiter mirror sits here: an internal write that skipped it would leave the host // displaying — and, on the next touch, re-imposing — the superseded value. notifyParamsFromModel(before, params); } void ReaSamplerProcessor::flushLatencyRestart() { // Cleared only once it can actually be delivered — an arm raised before the host connected // its handler waits for a later flush instead of evaporating. if (!componentHandler) return; if (!latencyRestartPending_.exchange(false, std::memory_order_acquire)) return; // Latched BEFORE the call: a host that services the restart synchronously re-enters this // object inside it, so the next commit must compare against the value the host is about to // read, not against the one it held before. const bool previouslyAnnounced = latencyAnnounced_.exchange( limiterEnabled_.load(std::memory_order_relaxed), std::memory_order_relaxed); // The SDK requires this on the UI thread and answers getLatencySamples only after the host's // own deactivate/reactivate — so the flag is long committed by the time the host asks. This // is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged // bus renegotiation (see initialize()); do not conflate. if (componentHandler->restartComponent(kLatencyChanged) == kResultOk) return; // A refused restart leaves the host's delay compensation on the OLD value, so the latch has // to come back off it: announcing a value the host never took would let a later toggle BACK // to that value arm nothing, stranding the host's view permanently. Re-armed instead, which // costs one retry per drain in a host that always refuses. The SDK documents no refusal // semantics, so whether any host returns non-kResultOk here is `[verify — DAW]`. latencyAnnounced_.store(previouslyAnnounced, std::memory_order_relaxed); latencyRestartPending_.store(true, std::memory_order_release); } void ReaSamplerProcessor::publishLimiterEnabled(bool on) { limiterEnabled_.store(on, std::memory_order_relaxed); limiter_.setEnabled(on); } void ReaSamplerProcessor::setLimiterEnabled(bool on) { // Rebased off the PROCESSOR's copy rather than taking a caller-supplied set: an editor // snapshot may carry edits it has not committed, and writing one back here would clobber // them. Everything else is setInstrumentParams', the one funnel every writer agrees through. InstrumentParams params = instrumentParams(); params.limiterEnabled = on; setInstrumentParams(params); } MasterBusMeter ReaSamplerProcessor::masterBusMeter() { MasterBusMeter m; // Consuming: each read takes the window and reinstalls its identity element, which is what // starts the next one. The audio thread's fold is an unconditional CAS against exactly that // (meter_accumulate.h owns the argument), so a fold interleaved with these exchanges lands // in one window or the other and is never dropped between them. m.peakL = instrument::engine::consumePeak(meterPeakL_); m.peakR = instrument::engine::consumePeak(meterPeakR_); m.minGain = instrument::engine::consumeMinGain(meterMinGain_); // NOT consumed: the clip is a latch the user clears, not a window. m.clip = meterClip_.load(std::memory_order_relaxed); return m; } void ReaSamplerProcessor::clearMasterBusClip() { meterClip_.store(false, std::memory_order_relaxed); } void ReaSamplerProcessor::publishLiveParams() { const int rate = builtSampleRate_.load(std::memory_order_relaxed); if (rate <= 0) return; const instrument::engine::LiveValues block = instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate)); // livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against // reloadInstrument's publish — held for the publish call only, not the fold above. std::lock_guard lock(livePublishMutex_); liveParams_.publish(block); } SampleRefs ReaSamplerProcessor::sampleRefs() { std::lock_guard lock(refsMutex_); return sampleRefs_; } ChannelMode ReaSamplerProcessor::channelMode() { std::lock_guard lock(channelModeMutex_); return channelMode_; } std::uint8_t ReaSamplerProcessor::previewVelocity() { std::lock_guard lock(previewMutex_); return previewVelocity_; } void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { // Clamp to [1,127] — 0 would be a note-off by convention, and a preview strike must sound. if (velocity < 1) velocity = 1; if (velocity > 127) velocity = 127; std::lock_guard lock(previewMutex_); previewVelocity_ = velocity; } int ReaSamplerProcessor::voiceCount() { std::lock_guard lock(voiceParamsMutex_); return voiceCount_; } void ReaSamplerProcessor::setVoiceCount(int count) { // Clamp to the shared pure-core range so the engine, state bytes, and editor control // can never disagree about the legal polyphony span. if (count < kMinVoiceCount) count = kMinVoiceCount; if (count > kMaxVoiceCount) count = kMaxVoiceCount; { std::lock_guard lock(voiceParamsMutex_); if (voiceCount_ == count) return; // no-op: don't churn a rebuild voiceCount_ = count; } // Light rebuild through the drain-slot swap (no bridge re-read, no WAV re-decode) so a // voice-param change never cuts a sounding tail. Same contract below. rebuildVoiceEngine(); } VoiceMode ReaSamplerProcessor::voiceMode() { std::lock_guard lock(voiceParamsMutex_); return voiceMode_; } void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { { std::lock_guard lock(voiceParamsMutex_); if (voiceMode_ == mode) return; voiceMode_ = mode; } rebuildVoiceEngine(); } MonoTrigger ReaSamplerProcessor::monoTrigger() { std::lock_guard lock(voiceParamsMutex_); return monoTrigger_; } void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { { std::lock_guard lock(voiceParamsMutex_); if (monoTrigger_ == trigger) return; monoTrigger_ = trigger; } rebuildVoiceEngine(); } void ReaSamplerProcessor::setMasterGainLinear(double linear) { // Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic // store — no rebuild, no lock (a post-sum 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; const float value = static_cast(linear); const float previous = masterGain_.exchange(value, std::memory_order_relaxed); // Gain's own notification funnel — it is the one exposed control that does not ride the // parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a // reload's republish of an unmoved gain writes nothing into a host's automation lane. if (paramNotifySuppressed_ || previous == value) return; notifyParamChanged(instrument::param::kParamMasterGain, instrument::engine::masterGainNormFromLinear(linear)); } 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 — 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(seq) << 16) | (static_cast(vel) << 8) | static_cast(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(seq) << 16) | static_cast(note & 0xFF); previewOffRequest_.store(packed, std::memory_order_release); } void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { { std::lock_guard lock(channelModeMutex_); // A deliberate choice either way: latch explicit even on a same-mode click so // auto-default stops fighting it. channelModeExplicit_ = true; if (channelMode_ == mode) return; // no decode change: don't churn a reload channelMode_ = mode; } // The output bus is fixed stereo (no bus repoint): reloading re-decodes off-thread // under the new mode and the RT path just keeps rendering. reloadInstrument(); } } // namespace reasampler::vst