self-contained playback — ComponentState v10 SampleRefs (path+intrinsics owned by the instance), reloadInstrument decodes bank-free, heal timer + poll-to-play removed; bank is a browser source

This commit is contained in:
2026-07-28 11:45:10 -04:00
parent 93e28f4ea5
commit 261a6affa5
11 changed files with 697 additions and 349 deletions
+151 -237
View File
@@ -22,16 +22,9 @@
#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
#include "reasampler_editor.h"
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
#include "sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained)
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h> // SetTimer/KillTimer (the reopen-heal retry's main-thread timer)
#endif
using namespace Steinberg;
using namespace Steinberg::Vst;
@@ -91,99 +84,16 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
return out;
}
#ifdef _WIN32
// --- Reopen-heal retry timer (the non-editor reload trigger; see the header) -----------
// HWND-less Win32 thread timer: SetTimer(nullptr, ...) queues WM_TIMER on the CALLING
// thread's message queue and the TIMERPROC fires from its pump — REAPER's main thread,
// where every reload path already runs (setState, setActive, the editor, this timer).
// A TIMERPROC carries no user data, so a tiny id->instance registry maps a fired timer
// back to its processor. Set/kill/fire all happen on the pumping thread; the mutex is
// defensive against an exotic host threading setState from elsewhere (in which case
// SetTimer would not fire there anyway and we degrade to the pre-fix editor-open heal).
constexpr UINT kHealRetryIntervalMs = 250; // fast enough to catch the tail of project load
constexpr int kHealRetryMax = 40; // ~10 s, then stop churning (e.g. missing WAV)
std::mutex g_healRegistryMutex;
std::vector<std::pair<std::uintptr_t, ReaSamplerProcessor*>> g_healRegistry;
void CALLBACK healTimerProc(HWND, UINT, UINT_PTR id, DWORD) {
ReaSamplerProcessor* target = nullptr;
{
std::lock_guard<std::mutex> lock(g_healRegistryMutex);
for (const auto& entry : g_healRegistry) {
if (entry.first == static_cast<std::uintptr_t>(id)) {
target = entry.second;
break;
}
}
}
if (!target) {
// Orphan fire (the processor disarmed/destroyed between queue and dispatch):
// stop the timer here — nobody else holds this id anymore.
KillTimer(nullptr, id);
return;
}
// The registry lock is RELEASED before the tick: healTick -> reloadFromBank takes
// reloadMutex_ then (via arm/disarm) the registry mutex — one consistent order.
target->healTick();
}
#endif // _WIN32
} // namespace
void ReaSamplerProcessor::armHealRetry() {
#ifdef _WIN32
// Main thread. Idempotent: an already-armed timer keeps its running countdown (the
// retry ticks call reloadFromBank, which calls back here on every failed rebuild).
if (healTimerId_ != 0) return;
const UINT_PTR id = SetTimer(nullptr, 0, kHealRetryIntervalMs, &healTimerProc);
if (id == 0) return; // no message pump on this thread / OS refusal: editor-open heal remains
healTimerId_ = static_cast<std::uintptr_t>(id);
healRetriesLeft_ = kHealRetryMax;
std::lock_guard<std::mutex> lock(g_healRegistryMutex);
g_healRegistry.emplace_back(healTimerId_, this);
#endif
}
void ReaSamplerProcessor::disarmHealRetry() {
#ifdef _WIN32
// Main thread. The common (already-disarmed) path costs one compare — this is called
// at the end of every successful reload.
if (healTimerId_ == 0) return;
KillTimer(nullptr, static_cast<UINT_PTR>(healTimerId_));
{
std::lock_guard<std::mutex> lock(g_healRegistryMutex);
g_healRegistry.erase(
std::remove_if(g_healRegistry.begin(), g_healRegistry.end(),
[this](const auto& entry) { return entry.second == this; }),
g_healRegistry.end());
}
healTimerId_ = 0;
#endif
}
void ReaSamplerProcessor::healTick() {
// Main thread (the heal timer's TIMERPROC). One bounded retry: reloadFromBank re-reads
// the bank over the bridge and itself disarms this timer when it builds an instrument
// (or the restored intent is gone). If it stays armed, count the budget down and give
// up at zero — a genuinely-missing WAV must not poll ext-state forever.
if (healTimerId_ == 0) return; // raced a disarm between queue and dispatch
--healRetriesLeft_;
reloadFromBank();
if (healTimerId_ != 0 && healRetriesLeft_ <= 0) disarmHealRetry();
}
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
// The host owns the returned reference. Cast up to the combined interface the SDK
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
}
// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here. The heal-retry
// disarm is defensive (terminate already disarms per the VST3 lifecycle): it removes this
// instance from the timer registry so a host that skips terminate can never leave a fired
// TIMERPROC holding a dangling pointer.
ReaSamplerProcessor::~ReaSamplerProcessor() { disarmHealRetry(); }
// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here.
ReaSamplerProcessor::~ReaSamplerProcessor() = default;
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
@@ -225,11 +135,9 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
}
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
// process() is not running at terminate. Stop the reopen-heal retry timer first (its
// tick would reload into a dying instance), then free the live + draining instruments
// and drain the graveyard. Take the pointers out of the atomics first so nothing else
// process() is not running at terminate: free the live + draining instruments and
// drain the graveyard. Take the pointers out of the atomics first so nothing else
// races them.
disarmHealRetry();
std::lock_guard<std::mutex> lock(reloadMutex_);
delete live_.exchange(nullptr);
delete draining_.exchange(nullptr);
@@ -244,20 +152,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// no reload could free while active). The build/drain are off the audio thread —
// setActive is a main/UI-thread call.
if (state) {
// NOTE: this is also the non-editor reload trigger's anchor — if this reload runs
// before the extension's PROJEXTSTATE is parseable, reloadFromBank arms the bounded
// heal-retry timer itself (see the header), so a restored instance played via host
// MIDI with the editor never opened still comes up sounding.
reloadFromBank();
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
// sample refs — it needs no bank read, so it plays regardless of whether the
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
reloadInstrument();
} else {
// An inactive instance has nothing to heal into — stop the retry; the reactivation
// reload above re-arms it if the bank is still not parseable then.
disarmHealRetry();
std::lock_guard<std::mutex> lock(reloadMutex_);
// process is guaranteed stopped: free EVERYTHING. The live instrument too — its
// voices are frozen mid-flight, and if it survived deactivation the reactivate
// reload would displace it into the DRAIN slot, resurrecting stale sustained
// voices as ghosts. Reactivation rebuilds from scratch (reloadFromBank above),
// voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above),
// so nothing is lost by clearing here.
delete live_.exchange(nullptr);
delete draining_.exchange(nullptr);
@@ -288,7 +192,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// 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 reloadFromBank).
// 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
@@ -301,7 +205,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// 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 + reloadFromBank run unconditionally on load
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).
@@ -335,8 +239,17 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// 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;
}
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadFromBank();
reloadInstrument();
return kResultOk;
}
@@ -369,6 +282,13 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
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));
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()),
@@ -398,6 +318,11 @@ void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
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_;
@@ -513,7 +438,7 @@ void ReaSamplerProcessor::setChannelMode(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.
reloadFromBank();
reloadInstrument();
}
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
@@ -530,7 +455,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
return kResultFalse;
}
std::string ReaSamplerProcessor::reloadFromBank() {
std::string ReaSamplerProcessor::reloadInstrument() {
// 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
// thread — process() only touches the atomic.
@@ -540,10 +465,27 @@ std::string ReaSamplerProcessor::reloadFromBank() {
// with it before publishing. Under reloadMutex_ no other reload races here.
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// 1. Read the live bank + resolve the project dir over the bridge (allocates,
// calls REAPER — fine here, off-thread).
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
// 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of
// truth for what to decode. The live bank blob, WHEN readable, is folded into the
// table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in
// mechanism and the S9 recapture sync in one — but its absence changes NOTHING
// below: a project restored before the extension's PROJEXTSTATE parses (or with
// the extension absent entirely) resolves + plays from the persisted refs. The
// project dir comes from REAPER itself (EnumProjects), not from the extension.
const std::string selId = selectedSampleId();
const PerformanceMap map = performanceMap();
const std::vector<std::string> ids = referencedSampleIds(selId, map);
SampleRefs refs;
{
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
// Hygiene: the owned table tracks exactly what the instance currently plays, so a
// de-referenced sample's entry drops here (never grows with browsing history).
retainRefs(sampleRefs_, ids);
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
}
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. The single-
@@ -563,115 +505,93 @@ std::string ReaSamplerProcessor::reloadFromBank() {
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
Keymap km;
bool haveKeymap = false;
if (banksJson) {
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
// zones against the live bank (STALE ids drop cleanly), decode each zone's WAV
// off-thread, and build the ZONED keymap. Each surviving zone plays its bank
// sample repitched from its effective root note (override > bank intrinsic > C4).
// A zone whose WAV fails to decode is dropped (not the whole map).
const PerformanceMap map = performanceMap();
Keymap km;
bool haveKeymap = false;
if (!map.empty()) {
const ResolvedPerformance resolved = resolvePerformance(*banksJson, map);
if (!resolved.zones.empty()) {
std::vector<DecodedZonePcm> decoded;
std::vector<ResolvedZone> kept;
decoded.reserve(resolved.zones.size());
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, rz.relativePath, mode);
if (!pcm) continue; // unreadable WAV -> drop this zone
kept.push_back(rz);
decoded.push_back(std::move(*pcm));
}
km = buildZonedKeymap(kept, decoded);
haveKeymap = !km.zones.empty();
}
}
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
// deliberately-selected capture chromatically across the whole keyboard. This is
// the default face — one picked capture, repitched from its root. NO first-
// sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in
// selectSample, so an un-picked instrument stays SILENT (the editor shows its
// "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy
// reversal of the S4 convenience default).
if (!haveKeymap) {
std::optional<SelectedSample> sel =
selectSample(*banksJson, selectedSampleId());
if (sel) {
// GA auto-default: channelModeFor computes the mode from the loaded capture's
// REQUESTED channel count (always 2 for extension captures; mono only for
// ingest-imported mono files). An unknown count (0) or explicit user choice
// returns the current mode unchanged. Decode-only: the output bus is fixed
// stereo, so no bus work follows a flip.
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
channelModeExplicit_);
mode = channelMode_;
}
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
// zones against the OWNED refs (an id with no ref drops cleanly), decode each
// zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays
// its sample repitched from its effective root note (override > ref intrinsic >
// C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped
// (not the whole map): the defined no-play, no crash, no retry loop.
if (!map.empty()) {
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
if (!resolved.zones.empty()) {
std::vector<DecodedZonePcm> decoded;
std::vector<ResolvedZone> kept;
decoded.reserve(resolved.zones.size());
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
sel->rootNote, sel->loop,
std::move(pcm->framesR));
haveKeymap = true;
resolvedId = selectedSampleId(); // the concrete pick that resolved
}
decodeRelative(projectDir, rz.relativePath, mode);
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
kept.push_back(rz);
decoded.push_back(std::move(*pcm));
}
km = buildZonedKeymap(kept, decoded);
haveKeymap = !km.zones.empty();
}
}
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
// deliberately-selected capture chromatically across the whole keyboard, resolved
// against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a
// selection with no ref) resolves to nothing, so an un-picked instrument stays
// SILENT (the editor shows its "pick a capture" empty state) rather than
// auto-playing sample #1 (S10 policy reversal of the S4 convenience default).
if (!haveKeymap) {
if (const SelectedSample* sel = findRef(refs, selId)) {
// GA auto-default: channelModeFor computes the mode from the loaded capture's
// REQUESTED channel count (always 2 for extension captures; mono only for
// ingest-imported mono files). An unknown count (0) or explicit user choice
// returns the current mode unchanged. Decode-only: the output bus is fixed
// stereo, so no bus work follows a flip.
{
std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
channelModeExplicit_);
mode = channelMode_;
}
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
sel->rootNote, sel->loop,
std::move(pcm->framesR));
haveKeymap = true;
resolvedId = selId; // the concrete pick that resolved
}
}
}
if (haveKeymap) {
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
// note-on never allocates. Floored at 2 so a valid window is always a real ring
// (which also covers a pathological host rate <= 0 — no rate literal needed).
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(km), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
if (haveKeymap) {
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
// note-on never allocates. Floored at 2 so a valid window is always a real ring
// (which also covers a pathological host rate <= 0 — no rate literal needed).
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(km), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
// a reload never cuts a sounding note; the next note-on plays the new state. The
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
// (process may still be mid-block reading it). A null `built` (no bank / unreadable
// (process may still be mid-block reading it). A null `built` (no ref / unreadable
// WAV) installs silence while the displaced tails still ring out via the drain.
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
// pointer is re-owned by the graveyard.
//
const bool loaded = static_cast<bool>(built);
publishBuiltLocked(std::move(built));
// Reopen-heal retry, the NON-editor trigger (see the header): this reload built NOTHING
// despite restored intent (a selection or zones) while the bridge is connected — during
// project load that almost always means the extension's PROJEXTSTATE block is not yet
// parseable — so arm the bounded main-thread retry timer. Every other outcome disarms:
// the timer only lives while there is something to heal. (Leaf-mutex order holds:
// reloadMutex_ -> registry mutex, matching the TIMERPROC which releases the registry
// before ticking into this function.)
const bool intent = !selectedSampleId().empty() || !performanceMap().empty();
if (!loaded && intent && bridge_.isConnected()) {
armHealRetry();
} else {
disarmHealRetry();
}
return resolvedId;
}
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
// reloadFromBank and rebuildVoiceEngine — the one safety-critical swap dance.
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
//
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
// the minimum installedAt process() published over the pointers it holds. Both
@@ -711,7 +631,7 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
}
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// Same Preserve-window derivation as reloadFromBank (kPreserveWindowMs at the host rate).
// Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate).
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
@@ -740,7 +660,7 @@ void ReaSamplerProcessor::retireIdleDrain() {
draining_.store(nullptr, std::memory_order_release);
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
// Prune what is now provably unreachable — the same monotone-generation proof as the
// reload path's reclaim (see reloadFromBank): an entry with installedAt < seen cannot be
// reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be
// held by process() now or ever again. The just-parked drain frees here immediately when
// process() has already published past it; otherwise on the next reload/retire/deactivate.
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
@@ -804,7 +724,7 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
if (decision.apply) {
// Apply the assignment as this instance's own selection (the same path a user card-pick
// takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
// takes) — the instrument updates its OWN state, never the bank. reloadInstrument below
// rebuilds against the new selection, so skip a redundant reload here.
setSelectedSampleId(decision.sampleId);
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
@@ -819,8 +739,9 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// --- S9: bank-generation change-detection -------------------------------------
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — setState
// already loaded the current bank, so a redundant reload on open would only churn. A later
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload —
// setState already loaded the instrument from its OWNED refs (pS), so a redundant reload
// on open would only churn. A later
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
std::int64_t currentGen = kBankGenerationAbsent;
@@ -832,36 +753,29 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
lastSeenBankGeneration_ = currentGen;
// REOPEN HEAL: the baseline-without-reload assumption above fails when the setState-time
// reload ran BEFORE the extension's PROJEXTSTATE was parseable during project load — the
// bridge read came back empty, so live_ installed SILENCE while the restored state (a
// selection or zones) says something SHOULD be loaded. Without this, the swallowed
// baseline left the instrument (preview AND host MIDI) dead until some param change
// forced a reload. Detect the mismatch on the first poll and reload; a deliberately
// empty instance (no selection, no zones) never churns, and a load that legitimately
// failed (missing WAV) costs one redundant, harmless reload on editor open.
bool healReload = false;
if (firstPoll && live_.load(std::memory_order_acquire) == nullptr) {
healReload = !selectedSampleId().empty() || !performanceMap().empty();
// LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones)
// but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had
// nothing to decode unless the bank happened to be readable already. Reload on this
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
// when readable, after which the table is non-empty and this never fires again (the
// next save is then self-contained). A deliberately-empty instance has no intent and
// never churns; a lift whose bank stays unreadable (or whose id went stale) retries a
// cheap null publish on the editor cadence only. This is a MIGRATION convenience for
// old projects, NOT a playback dependency — a v10 blob plays from its refs with no
// poll at all (pS).
bool legacyLift = false;
if (!genChanged && !result.applied && sampleRefs().empty()) {
legacyLift = !selectedSampleId().empty() || !performanceMap().empty();
}
if (genChanged || result.applied || healReload) {
reloadFromBank(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
// Report the reload (S9 change or reopen heal) distinctly from an S8 apply so the
// editor re-snapshots its bank view.
result.reloaded = genChanged || healReload;
}
// Heal RETRY: if the heal reload above STILL left nothing live (the first poll was
// itself too early — the bank blob still absent/unparseable), reset the sentinel so
// the NEXT tick re-arms the first-poll heal instead of spending it one-shot. Bounded
// by the intent check inside the heal (a deliberately-empty instance never sets
// healReload, so never re-arms). This also heals a bank whose generation counter was
// never bumped: `bankGenerationChanged` is a plain != against a counter that stays 0
// for such a bank (0 != 0 never fires), so the generation path alone could never
// recover it.
if (healReload && live_.load(std::memory_order_acquire) == nullptr) {
lastSeenBankGeneration_ = -1;
if (genChanged || result.applied || legacyLift) {
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
// Report the reload distinctly from an S8 apply so the editor re-snapshots its bank
// view. A legacy lift counts only when it actually landed an instrument (otherwise
// every retry tick would churn the editor's caches for nothing).
result.reloaded =
genChanged ||
(legacyLift && live_.load(std::memory_order_acquire) != nullptr);
}
return result;
}