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
+53 -51
View File
@@ -96,10 +96,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// retired-slot free is single-writer; never taken on the audio thread.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Mint this reload's generation number first so the built instrument is stamped
// before publishing.
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// 1. Self-contained resolution: the instance-owned refs table is the source of truth.
// The live bank blob, when readable, is folded in first (refreshRefsFromBank — the
// browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes
@@ -124,17 +120,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
// capture's own channel count below, before the decode.
ChannelMode mode = channelMode();
// Snapshot the voice-system parameters once — baked into the built engine's
// construction (immutable config; a later change rebuilds).
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
@@ -177,17 +162,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
}
}
if (havePlayable) {
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
// valid window is always a real ring, covering a pathological host rate <= 0 too.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
if (havePlayable) built = buildInstrumentLocked(std::move(sample));
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
// header). A null `built` (no ref / unreadable WAV) installs silence while any
@@ -246,9 +221,10 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
// ONE reload for the re-point and the reset together: it decodes the new file and
// publishes the neutral parameters in the same swap.
reloadInstrument();
// The bake's reset may have flipped the limiter; deliver the host's latency restart here
// rather than leaving it to the editor's tick, so an adopt is correct with no editor open.
// At the tail for the same reason setState's is (see there).
// The bake's reset may have flipped the limiter; delivering the restart here rather than
// leaving it to the editor's tick is a LATENCY improvement, not a correctness one — the
// bake chain only ever runs from that tick, so the arm would be drained on the next one
// anyway. At the tail for the same reason setState's is (see there).
flushLatencyRestart();
}
@@ -301,6 +277,10 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
return e->installedAt < seen;
}),
graveyard_.end());
// Any publish supersedes the deactivate's park: whatever is installed here is the newer
// truth, and a park surviving it would be reinstalled over this instrument at the next
// activation (the setState-while-inactive case).
dormantSample_.reset();
LoadedInstrument* prev = live_.exchange(built.release());
// A bake's reset gain lands here rather than at its call site, so the gain and the
// capture it belongs to become audible to process() within one block of each other.
@@ -317,6 +297,34 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
}
std::unique_ptr<LoadedInstrument>
ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) {
// REQUIRES reloadMutex_ held. The ONE construction of a playable snapshot, so the three
// callers (full reload, voice-param rebuild, reactivation) cannot drift on the generation
// stamp, the voice-system snapshot or the ring size.
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
// Baked into the engine's construction (immutable config; a later change rebuilds).
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized
// here so process()-time note-on never allocates. Floored at 2 so a valid window is always
// a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a
// reactivation after the host changed its rate gets a ring sized for the new one.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
return std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
void ReaSamplerProcessor::rebuildVoiceEngine() {
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
// the engine around a copy of the live instrument's already-decoded SampleData — no
@@ -324,31 +332,25 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// Same Preserve-window derivation as reloadInstrument.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Deep-copy the decoded sample: safe to read concurrently with process() because the
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
// freed.
SampleData sample = cur->sample;
auto built = std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
publishBuiltLocked(std::move(built));
publishBuiltLocked(buildInstrumentLocked(cur->sample));
}
bool ReaSamplerProcessor::resumeDormantInstrument() {
// Off the audio thread (setActive only). The reactivation half of the lifetime split: the
// voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
std::lock_guard<std::mutex> lock(reloadMutex_);
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the
// activation state — its voices have never rendered, and it has already superseded the
// park. Rebuilding here would displace a correct instrument into the drain slot.
if (live_.load(std::memory_order_acquire)) return true;
if (!dormantSample_) return false;
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
// second copy of the PCM for the whole active lifetime.
publishBuiltLocked(buildInstrumentLocked(std::move(*dormantSample_)));
return true;
}
void ReaSamplerProcessor::retireIdleDrain() {