// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle: // reloadInstrument (self-contained refs resolve + WAV decode + SampleData build), the // safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild, // idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the // usage publish. Nothing here runs on the audio thread — process() only touches the // atomics this family publishes; the atomic-pointer-swap pattern gains no virtual seam. #include "shell/instrument/reasampler_processor.h" #include #include #include #include #include #include #include #include #include #include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution) #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) #include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision #include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained) #include "core/util/file_bytes.h" // shared whole-file loader #include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse) #include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey namespace reasampler::vst { using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives using namespace reasampler::wire; // assignment_request + sample_usage wire records using capture::extractFloatFrames; using capture::parseWavLayout; using capture::resolveBankFile; using capture::WavLayout; using util::readFileBytes; namespace { // Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is // materially heavier than a Varispeed voice, so a note-on past the cap is dropped rather // than glitching. 8 is conservative pending DAW profiling; fixed regardless of the // user-set voiceCount (1..32) so raising polyphony never multiplies shifter CPU past budget. constexpr std::size_t kPreserveVoiceCap = 8; // Mints a fresh publish identity (32 lowercase hex chars) for either the persisted // instanceGuid_ or the in-memory usageNonce_. Uniqueness, not cryptographic strength, is // the requirement — planUsagePublish resolves a collision fail-safe anyway. std::string mintUsageInstanceGuid() { std::random_device rd; std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); std::uniform_int_distribution dist; char buf[33] = {0}; std::snprintf(buf, sizeof(buf), "%016llx%016llx", static_cast(dist(gen)), static_cast(dist(gen))); return std::string(buf); } // Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only), // and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono // for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any // resolve/read/decode failure — the caller plays silence. std::optional decodeRelative(const std::string& projectDir, const std::string& relativePath, ChannelMode mode) { const std::string abs = resolveBankFile(projectDir, relativePath); if (abs.empty()) return std::nullopt; const std::vector bytes = readFileBytes(abs); const WavLayout layout = parseWavLayout(bytes); if (!layout.valid) return std::nullopt; std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode, static_cast(layout.sampleRate)); if (out.monoFrames.empty()) return std::nullopt; return out; } // The ONE decode + build the reload and the bake snapshot share, so a baked render can // never be built from a different fold than the one the user is hearing. BuiltCapture buildFromRef(const SelectedSample& sel, const InstrumentParams& params, const std::string& projectDir, ChannelMode mode, int renderRate) { std::optional pcm = decodeRelative(projectDir, sel.relativePath, mode); if (!pcm) return {}; if (!sourceRateSupported(pcm->sampleRate, renderRate)) return {std::nullopt, true}; SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate); if (!sample.playable()) return {}; return {std::move(sample), false}; } } // namespace std::string sourceRateRefusalText() { return "sample rate over " + std::to_string(static_cast(kMaxSourceRateRatio)) + "x the host's"; } std::string ReaSamplerProcessor::reloadInstrument() { // OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the // retired-slot free is single-writer; never taken on the audio thread. std::lock_guard lock(reloadMutex_); // 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 // nothing below — a project restored before PROJEXTSTATE parses (or with the // extension absent) resolves + plays from the persisted refs. const std::string selId = selectedSampleId(); const InstrumentParams params = instrumentParams(); const std::vector ids = referencedSampleIds(selId); SampleRefs refs; { std::optional banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey); std::lock_guard rl(refsMutex_); if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); // The LOAD path never prunes the owned table: dropping entries on a transient bank // miss could destroy the owned intrinsics of the previous selection — the ONE copy // that survives with the extension absent. Hygiene lives at the PERSIST boundary // (getState filters via retainRefs to what the instance plays). refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) } const std::string projectDir = bridge_.activeProjectDir(); // 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(); std::string resolvedId; std::unique_ptr built; SampleData sample; bool havePlayable = false; bool rateRefused = false; // 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard // repitched from its effective root. No first-sample fallback: an empty selection // (or one with no ref) resolves to nothing, so an un-picked instrument stays silent // rather than auto-playing sample #1. A missing/unreadable WAV is the same defined // no-play — no crash, no retry loop. if (const SelectedSample* sel = findRef(refs, selId)) { // Auto-default: channelModeFor computes the mode from the loaded capture's channel // count — 1 for an ingested mono file or a capture whose channels came out // bit-identical and collapsed, 2 otherwise. An unknown count (0) or an explicit // user choice keeps the mode. { std::lock_guard cm(channelModeMutex_); channelMode_ = channelModeFor(sel->channelCount, channelMode_, channelModeExplicit_); mode = channelMode_; } BuiltCapture decoded = buildFromRef(*sel, params, projectDir, mode, hostRenderRate()); rateRefused = decoded.rateRefused; if (decoded.sample) { sample = std::move(*decoded.sample); havePlayable = true; // Point the built snapshot at the instance's ONE engine-facing block and seed the // MODEL block from the very PlayParams the voices latch, so an untouched knob folds // to the same frames the build resolved and a note-on with a live block sounds // identical to one without. process() merges the seed into automationLive_ at the // top of the first block after this, which is where the swap below becomes visible // too — so the new snapshot's first note reads it. sample.live = &automationLive_; sample.liveContours = &liveContours_; // BEFORE the publish: the publish is what makes the audio thread re-merge, and the // merge resolves every held automation stage time against this rate. Stored after, // a merge in the window between them would resolve them against the previous // build's rate — or, on the first-ever build, against 0, where secondsToFrames // collapses every automated envelope stage to zero frames. builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed); { // reloadMutex_ (held for this whole function) nests livePublishMutex_ here, the // same nesting restampHostRateLocked takes under its own reloadMutex_-held // callers; publishLiveParams never holds reloadMutex_. std::lock_guard lp(livePublishMutex_); liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); // The model's contours, so a reload-tier contour edit (a point added, deleted or // made hard, a drag's release) reaches the drain's sounding voices too, and a // courier publish from an abandoned gesture cannot outlive the reload. liveContours_.publish(instrument::engine::foldContours(sample.play)); } resolvedId = selId; // the concrete pick that resolved } } sourceRateRefused_.store(rateRefused, std::memory_order_relaxed); 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 // displaced tails still ring out via the drain. publishBuiltLocked(std::move(built)); // 4. Publish this instance's held captures so the extension's prune can never reclaim // them. Regardless of decode success: the holds are the refs the instance retains // (its play-set), not what decoded — a transiently unreadable WAV stays protected. publishUsage(refs, ids); return resolvedId; } std::string ReaSamplerProcessor::usageInstanceGuid() { std::lock_guard lock(usageMutex_); if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); return instanceGuid_; } BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) { const std::string selId = selectedSampleId(); if (selId.empty()) return {}; const InstrumentParams params = instrumentParams(); SampleRefs refs; { std::lock_guard rl(refsMutex_); refs = sampleRefs_; } const SelectedSample* sel = findRef(refs, selId); if (!sel) return {}; // No live block is attached: the bake renders the stored parameter set, which is what // every knob has already written, rather than whatever the audio thread is observing. return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate); } void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset, double masterGainLinear) { { std::lock_guard rl(refsMutex_); bool replaced = false; for (SampleRefEntry& existing : sampleRefs_) { if (existing.sampleId == entry.sampleId) { existing = entry; replaced = true; break; } } if (!replaced) sampleRefs_.push_back(entry); } setSelectedSampleId(entry.sampleId); setInstrumentParams(reset); { // ARMED, not stored: publishBuiltLocked applies it inside the same locked section as // the pointer swap. Storing it here instead would put the reset gain under the OLD // capture for the whole bridge-read-plus-WAV-decode the reload below runs first. std::lock_guard lock(reloadMutex_); gainAtNextPublish_ = masterGainLinear; } // 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; 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(); // The reset's gain notification, armed under reloadMutex_ inside that reload. flushGainNotify(); } void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, const std::vector& ids) { if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do UsageRecord mine; mine.trackGuid = bridge_.currentTrackGuid(); for (const std::string& id : ids) { if (const SelectedSample* ref = findRef(refs, id)) { if (!ref->relativePath.empty()) { mine.holds.push_back(UsageHold{id, ref->relativePath}); } } } std::lock_guard lock(usageMutex_); // A never-published instance with nothing held writes nothing (no key litter); once an // identity exists, empties do publish (releasing protected holds). if (instanceGuid_.empty() && mine.holds.empty()) return; if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); // The per-lifetime owner nonce (UsageRecord.ownerNonce) lets planUsagePublish prove // "exactly this incarnation wrote the key" — a same-track sibling's byte-identical hold // set can never pass as ours, so siblings always union rather than clean-replace. if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); mine.ownerNonce = usageNonce_; const std::optional existing = bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); const UsagePublishPlan plan = planUsagePublish(existing, mine); if (plan.remint) { // Cloned onto another track (FX copy / track duplication): take a fresh identity; // the abandoned old record dies by the extension's liveness rule once its track no // longer hosts an instance. instanceGuid_ = mintUsageInstanceGuid(); } else if (plan.skipWrite) { return; // idle tick, or a union that adds nothing — no ext-state churn } bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); } void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { // REQUIRES reloadMutex_ held (see the header's drain-slot proof). const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), [seen](const std::unique_ptr& e) { 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. // live_.exchange above and the gain store below are two independent relaxed atomics // with no ordering between them, so the honest bound is "within one block", not "the // same instant" — a tail still ringing out of the drain does take the new gain, one // gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see // builtSampleRate_). if (gainAtNextPublish_) { // The MIRROR is inline (that is the sound this publish belongs to); the host // notification is ARMED and delivered after reloadMutex_ is released. performEdit // reaches the host handler, a host may re-enter this object synchronously from it, and // setActive(false) takes this same non-recursive mutex — the identical hazard the // latency restart is deferred for. const bool moved = publishMasterGainLinear(*gainAtNextPublish_); gainAtNextPublish_.reset(); // Armed only on a real change, so a reset that lands on the gain already set writes // nothing into a host's automation lane — the same compare setMasterGainLinear makes. if (moved) gainNotifyPending_.store(true, std::memory_order_release); } LoadedInstrument* evicted = draining_.exchange(prev); if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); } std::unique_ptr 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 vp(voiceParamsMutex_); builtVoiceCount = voiceCount_; builtVoiceMode = voiceMode_; builtMonoTrigger = monoTrigger_; } // Pre-sized here so process()-time note-on never allocates. const std::int64_t preserveWindow = preserveWindowFrames(sample); const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; return std::make_unique( std::move(sample), static_cast(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 // bridge, no disk — and publishes through the same drain-slot swap. std::lock_guard lock(reloadMutex_); LoadedInstrument* cur = live_.load(std::memory_order_acquire); if (!cur) return; // nothing loaded: the new params bake into the next real reload. // 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. 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 lock(reloadMutex_); // Every frame count in a built sample — and in the live block published beside it — was // resolved for the host rate of its build, and setupProcessing may have changed that rate // while inactive (or a setState load ran before the host reported one). Only the resolve // depends on it, never the decode, so both paths below re-stamp rather than re-read a file // that may since have gone missing. const int renderRate = hostRenderRate(); // 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 at an unchanged rate would displace a correct instrument into the // drain slot; at a changed one the rebuild is the re-stamp, and there is nothing sounding // to displace. if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) { if (cur->sample.renderRate == renderRate) return true; SampleData restamped = cur->sample; if (!restampHostRateLocked(restamped, renderRate)) return false; publishBuiltLocked(buildInstrumentLocked(std::move(restamped))); return true; } if (!dormantSample_) return false; // The activation is still where a bank change made with NO EDITOR OPEN is picked up: // pollBankSync, the only other route to either of the two calls below, runs off the // editor's sync tick and nothing else. Both are a GetProjExtState plus a parse — no disk // and no decode, which is what lets them stay on a path whose whole point is skipping // those two. const std::string selId = selectedSampleId(); const std::vector ids = referencedSampleIds(selId); SampleRefs refs; bool sourceMoved = false; { std::optional banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey); std::lock_guard rl(refsMutex_); if (banksJson) { const SelectedSample* before = findRef(sampleRefs_, selId); const std::optional was = before ? std::optional(*before) : std::nullopt; refreshRefsFromBank(sampleRefs_, *banksJson, ids); const SelectedSample* now = findRef(sampleRefs_, selId); sourceMoved = !was || !now || !sameDecodeSource(*was, *now); } refs = sampleRefs_; } // A recapture that landed while this instance was inactive makes the park the WRONG audio, // and refreshing the refs without re-decoding would leave the table naming one file while // the voices play another. Hand back to the full reload, which decodes the new one. if (sourceMoved) return false; if (!restampHostRateLocked(*dormantSample_, renderRate)) 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. Disengaged BEFORE the build so a // throwing build leaves nothing to resume — the next activation then takes the reload // rather than publishing an empty sample as permanent silence. SampleData resumed = std::move(*dormantSample_); dormantSample_.reset(); publishBuiltLocked(buildInstrumentLocked(std::move(resumed))); // The prune-protection republish reloadInstrument owes on every publish: it is also what // heals an rsusage_ key whose write failed when this instance last set its state. publishUsage(refs, ids); return true; } bool ReaSamplerProcessor::restampHostRateLocked(SampleData& sample, int renderRate) { if (sample.renderRate == renderRate) return true; if (!sourceRateSupported(sample.sampleRate, renderRate)) return false; sample.renderRate = renderRate; // The model's stored seconds, which is exactly what reloadInstrument resolves (via // resolveCapture) — so the re-stamp and a disk reload land on the same frames. sample.play = resolvePlay(instrumentParams().play, sample.outputRate()); // BEFORE the caller's publish, for the reason reloadInstrument gives at the same store. builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed); std::lock_guard lp(livePublishMutex_); liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); // Symmetric with reloadInstrument's own publish pair: a re-stamp is a rebuild too, so the // drain's sounding voices need the model's contours refreshed beside the live params. liveContours_.publish(instrument::engine::foldContours(sample.play)); return true; } void ReaSamplerProcessor::retireIdleDrain() { // Cheap early-out before the lock: 0 means "no drain, or it still sounds". const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); if (idleGen == 0) return; std::lock_guard lock(reloadMutex_); LoadedInstrument* drain = draining_.load(std::memory_order_acquire); // Retire only if the publication names the drain currently in the slot — a stale value // (an already-evicted, older drain) can never match the newer occupant's installedAt // (monotone in generation), closing a mid-swap race by identity rather than timing. if (!drain || drain->installedAt != idleGen) return; draining_.store(nullptr, std::memory_order_release); graveyard_.push_back(std::unique_ptr(drain)); // Prune what is now provably unreachable (same monotone-generation proof as // reloadInstrument's reclaim). const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), [seen](const std::unique_ptr& e) { return e->installedAt < seen; }), graveyard_.end()); } bool ReaSamplerProcessor::legacyLiftShouldRun() { // Terminating guard for the pre-v10 legacy lift (caller has already established // refs-empty + intent). Once concluded, the steady state is one relaxed load — no bank // read, no parse, no reload churn. if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; const LegacyLiftDecision decision = legacyLiftDecision( bridge_.readReasamplerExtState(kProjExtBanksKey), referencedSampleIds(selectedSampleId())); if (decision == LegacyLiftDecision::Stale) { // Provably stale: give up permanently. A later bank change that re-introduces an // id bumps the generation, and genChanged refreshes the refs without this latch. legacyLiftConcluded_.store(true, std::memory_order_relaxed); return false; } return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) } ReaSamplerProcessor::BankSyncResult ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { // Off the audio thread (editor's UI timer only). A disconnected bridge yields nullopt // for both reads, so this no-ops cleanly. BankSyncResult result; // Park an idle drain snapshot in the graveyard on the same cadence that drives // reloads, so an edited-away instrument stops costing memory as soon as tails die. retireIdleDrain(); // --- Assignment-request consume first ------------------------------------------- // Decodes the pending assignment request (nullopt if absent/malformed), resolves its // sampleId against the live bank blob (an unresolvable pair is dropped), then runs the // pure consume decision against this instance's persisted marker. std::optional request; if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { request = decodeAssignmentRequest(*raw); } bool resolves = false; if (request) { // Resolve against the CURRENT bank blob (a fresh read, so a request whose sample // was rolled back by an extension undo resolves to nullopt -> dropped). if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { resolves = selectSample(*banksJson, request->sampleId).has_value(); } } // Read + conditionally write lastConsumed under one lock scope so a concurrent // getState cannot observe a stale marker between two separate acquisitions. std::int64_t lastConsumed = 0; const AssignConsumeDecision decision = [&] { std::lock_guard lock(assignMarkerMutex_); lastConsumed = lastConsumedAssignGeneration_; const AssignConsumeDecision d = consumeDecision(request, lastConsumed, resolves, isFocusedTarget); // Advance the persisted marker whenever the decision consumed the request // (applied or dropped-as-seen); a non-target instance leaves it unchanged so it // stays eligible if focus later lands here. if (d.consumedGeneration != lastConsumed) { lastConsumedAssignGeneration_ = d.consumedGeneration; } return d; }(); if (decision.apply) { // Apply as this instance's own selection (the instrument updates its own state, // never the bank); reloadInstrument below rebuilds against it. The parameter set // carries over to the new capture — there is only one, and it governs whatever is // loaded (the peer of the editor's Browse Load). setSelectedSampleId(decision.sampleId); result.applied = true; } // --- Bank-generation change-detection ------------------------------------------- // First poll (lastSeenBankGeneration_ == -1 sentinel) baselines without a reload — // setState already loaded from owned refs, so a redundant reload on open would only // churn. A later generation change (recapture/ingest/remove/undo) drives the reload. std::int64_t currentGen = kBankGenerationAbsent; if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { currentGen = parseBankGeneration(*rawGen); } const bool firstPoll = (lastSeenBankGeneration_ < 0); const bool genChanged = !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); lastSeenBankGeneration_ = currentGen; // Legacy lift (pre-v10 blob): restored state carries intent but no owned refs (old // blobs had no path table). Reload on this tick until reloadInstrument folds the bank // blob into the refs (after which this never fires again — the next save is // self-contained). legacyLiftShouldRun concludes permanently once the bank parses and // no referenced id resolves — a migration convenience only, never a playback // dependency (a v10 blob plays from its refs with no poll at all). bool legacyLift = false; if (!genChanged && !result.applied && sampleRefs().empty()) { legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun(); } if (genChanged || result.applied || legacyLift) { reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play // Reported distinctly from an applied assignment so the editor re-snapshots its // bank view; a legacy lift counts only when it actually landed an instrument. result.reloaded = genChanged || (legacyLift && live_.load(std::memory_order_acquire) != nullptr); } return result; } } // namespace reasampler::vst