Decouple the instrument reload from VST3 activation, and make the master meter's accumulate exact

This commit is contained in:
2026-08-02 12:41:57 -04:00
parent 4b0b03d8d5
commit 5c6525fb91
17 changed files with 462 additions and 239 deletions
+43 -30
View File
@@ -22,6 +22,7 @@
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
@@ -137,7 +138,10 @@ public:
// What the audio thread published since the LAST call: peaks maxed and minGain minimised
// across every block in that window. CONSUMING — it resets the accumulators as it reads, so
// exactly one reader may call it, and that reader is the editor's meter tick. UI thread.
// exactly one reader may call it, and that reader is the editor's meter tick. Two live
// editors would each consume half the windows and both meters would read low; what makes
// that unreachable is the HOST calling createView once per instance, not anything this
// plugin enforces — createView allocates a new editor on every call. UI thread.
MasterBusMeter masterBusMeter();
void clearMasterBusClip();
@@ -248,9 +252,10 @@ public:
// Delivers the armed kLatencyChanged restart, at most once per armed window, and does
// nothing when none is armed. Split off the commit because the SDK requires this on the UI
// thread AND a host may service it synchronously — deactivate/reactivate, which reaches our
// setActive(true) and its reloadInstrument — so it must never run nested inside a mouse
// handler. The editor's sync tick is the general drain; the two callers that can commit with
// no editor open (setState, adoptBakedCapture) flush themselves at their own tails.
// setActive(true) — so it must never run nested inside a mouse handler. The editor's sync
// tick is the general drain; setState flushes at its own tail because it can commit with no
// editor open, and adoptBakedCapture does so only to save a tick (its chain runs from that
// same tick, so its arm would drain on the next one regardless).
void flushLatencyRestart();
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
@@ -283,6 +288,18 @@ private:
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
void rebuildVoiceEngine();
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
// rebuilds the voice state around the parked sample and publishes it through the same
// drain-slot swap. True also when a publish landed while inactive, which needs no rebuild.
// False when there is nothing to activate from — the caller then falls back to a full
// reload, which is where the pre-v10 legacy lift lives. Off the audio thread only.
bool resumeDormantInstrument();
// Builds a playable snapshot around `sample` at the current voice-system parameters and
// host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
// construction site shared by the reload, the voice-param rebuild and the reactivation.
std::unique_ptr<LoadedInstrument> buildInstrumentLocked(SampleData sample);
// Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress
// (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse).
bool legacyLiftShouldRun();
@@ -293,22 +310,6 @@ private:
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Folds one block's reading into the accumulator it belongs to — a running max for a peak,
// a running min for the limiter's gain. Read-modify-write rather than a bare store, so the
// ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512 all reach the meter
// instead of the one it happened to sample. Relaxed throughout: the accumulators are
// advisory, and no other state is ordered against them. Block rate, never per frame.
static void foldPeak(std::atomic<float>& acc, float blockPeak) {
if (blockPeak > acc.load(std::memory_order_relaxed)) {
acc.store(blockPeak, std::memory_order_relaxed);
}
}
static void foldMinGain(std::atomic<float>& acc, float blockMinGain) {
if (blockMinGain < acc.load(std::memory_order_relaxed)) {
acc.store(blockMinGain, std::memory_order_relaxed);
}
}
// Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
// only the ADVISORY level, which is a last-block reading: the meter accumulators need
// nothing here, because a block that emitted no audio contributes no peak and no gain
@@ -383,6 +384,13 @@ private:
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_;
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
// and no WAV decode: activation is "the audio thread may run", not "the sample is
// rebuilt". Holds a value only while inactive, and any publish drops it (publishBuiltLocked
// owns why). Voice state is deliberately NOT parked with it; see setActive. Guarded by
// reloadMutex_.
std::optional<SampleData> dormantSample_;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread.
std::mutex selectionMutex_;
@@ -479,20 +487,25 @@ private:
// getLatencySamples answers from.
instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false};
// Set by the commit funnel when the enable actually changed, cleared only by
// flushLatencyRestart. A sticky bool and not a count on purpose: the host is being told to
// re-ASK, so N changes before one flush need exactly one restart, and whatever
// getLatencySamples answers at that moment is the truth being announced.
// Raised (and lowered) by the commit funnel from the difference between the enable and
// latencyAnnounced_, cleared by flushLatencyRestart on delivery. A bool and not a count on
// purpose: the host is being told to re-ASK, so N changes before one flush need exactly one
// restart, and whatever getLatencySamples answers at that moment is the truth announced.
std::atomic<bool> latencyRestartPending_{false};
// The enable the host was last told about — false initially, which is what an instance
// that has announced nothing reports. Every arm is judged against this, so a change that
// returns to the announced state costs no restart.
std::atomic<bool> latencyAnnounced_{false};
// What the audio thread publishes about the output bus each block, relaxed. The peaks and
// minGain ACCUMULATE (max / min) across every block since the UI last read, and
// masterBusMeter() resets them as it reads — the fix for a bar that displayed roughly one
// block in fifty. No dB, no ballistics, no hold timer here;
// the UI runs those off these values and its own elapsed time.
std::atomic<float> meterPeakL_{0.f};
std::atomic<float> meterPeakR_{0.f};
std::atomic<float> meterMinGain_{1.f};
// masterBusMeter() consumes them as it reads — the fix for a bar that displayed roughly one
// block in fifty. The folds and the identity elements below are meter_accumulate's; no dB,
// no ballistics, no hold timer here — the UI runs those off these values and its own
// elapsed time.
std::atomic<float> meterPeakL_{instrument::engine::kMeterPeakIdentity};
std::atomic<float> meterPeakR_{instrument::engine::kMeterPeakIdentity};
std::atomic<float> meterMinGain_{instrument::engine::kMeterGainIdentity};
std::atomic<bool> meterClip_{false};
// The embed strip's activity level: the LAST block's loudest channel, plainly overwritten.