Q-W2v: split VST god-modules — editor 8 face-axis TUs (+pure layout hoist), processor 3 TUs, component_state_io codec split (extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, 61/61 green
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument
|
||||
// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap
|
||||
// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param
|
||||
// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8
|
||||
// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp
|
||||
// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle
|
||||
// TU) only touches the atomics this family publishes; the atomic-pointer-swap
|
||||
// pattern deliberately gains NO virtual seam (T4-29).
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
|
||||
#include "core/wire/sample_usage.h" // pS-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
|
||||
|
||||
namespace {
|
||||
|
||||
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
|
||||
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
|
||||
// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the
|
||||
// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays
|
||||
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
|
||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||
|
||||
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
|
||||
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
|
||||
// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic
|
||||
// strength) is the requirement: two instances sharing a key is the copy-collision
|
||||
// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision
|
||||
// vanishingly unlikely. Off-thread only.
|
||||
std::string mintUsageInstanceGuid() {
|
||||
std::random_device rd;
|
||||
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
||||
std::uniform_int_distribution<std::uint64_t> dist;
|
||||
char buf[33] = {0};
|
||||
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
|
||||
static_cast<unsigned long long>(dist(gen)),
|
||||
static_cast<unsigned long long>(dist(gen)));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03).
|
||||
// Off-thread only (blocking file I/O). Empty on any failure — the caller treats
|
||||
// an unreadable WAV as "nothing to play".
|
||||
|
||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
|
||||
// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for
|
||||
// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path
|
||||
// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no
|
||||
// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by
|
||||
// the zoned build and the single-capture path so both decode identically for the active mode.
|
||||
std::optional<DecodedZonePcm> 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<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid) return std::nullopt;
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
if (out.monoFrames.empty()) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
|
||||
// Mint this reload's generation number first so we can stamp the built instrument
|
||||
// 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. 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);
|
||||
// The LOAD path never prunes the owned table: dropping entries here on a transient
|
||||
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
|
||||
// copy that survives with the extension absent. Entries for de-referenced ids stay
|
||||
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
|
||||
// where getState filters its snapshot via retainRefs to what the instance plays.
|
||||
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-
|
||||
// capture branch below may auto-default it (GA) before its decode.
|
||||
ChannelMode mode = channelMode();
|
||||
// Phase S: snapshot the voice-system parameters once — they are baked into the built
|
||||
// engine's construction (the engine's config is immutable; 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;
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
|
||||
// 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, 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);
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
publishBuiltLocked(std::move(built));
|
||||
|
||||
// 5. pS-usage: publish this instance's held captures so the extension's prune can
|
||||
// never reclaim them (see publishUsage). AFTER the instrument swap, still off the
|
||||
// audio thread and under reloadMutex_. Publishes regardless of decode success:
|
||||
// the holds are the refs the instance RETAINS (its play-set), not what decoded —
|
||||
// a transiently unreadable WAV must stay protected.
|
||||
publishUsage(refs, ids);
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
const std::vector<std::string>& 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<std::mutex> lock(usageMutex_);
|
||||
// A never-published instance with nothing held writes nothing — no key litter for
|
||||
// fresh/empty instances. Once an identity exists, empties DO publish (they release
|
||||
// holds the prune would otherwise keep protecting).
|
||||
if (instanceGuid_.empty() && mine.holds.empty()) return;
|
||||
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
|
||||
// The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so
|
||||
// planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track
|
||||
// sibling's byte-identical hold set can never pass as ours (its nonce differs), so
|
||||
// siblings always union and never clean-replace over each other's held paths.
|
||||
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
|
||||
mine.ownerNonce = usageNonce_;
|
||||
|
||||
const std::optional<std::string> existing =
|
||||
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
||||
const UsagePublishPlan plan = planUsagePublish(existing, mine);
|
||||
if (plan.remint) {
|
||||
// This state was cloned onto another track (FX copy / track duplication): take a
|
||||
// fresh identity and leave the original's record untouched. The abandoned old
|
||||
// identity's record dies by the extension's liveness rule when its track no
|
||||
// longer hosts an instance. getState persists the new guid on the next save.
|
||||
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<LoadedInstrument> built) {
|
||||
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||
// 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
|
||||
// slots are monotone in installedAt, so seen is monotone and any future process()
|
||||
// load yields installedAt >= seen — an entry below seen is provably unreachable
|
||||
// (see the header proof). Remaining entries drain at setActive(false) / terminate()
|
||||
// when process is guaranteed stopped.
|
||||
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<LoadedInstrument>& e) {
|
||||
return e->installedAt < seen;
|
||||
}),
|
||||
graveyard_.end());
|
||||
LoadedInstrument* prev = live_.exchange(built.release());
|
||||
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
// OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract:
|
||||
// a voice-param change touches NO audio data, so this rebuilds the engine
|
||||
// around a COPY of the live instrument's already-decoded keymap — no bridge, no disk —
|
||||
// and publishes through the same drain-slot swap, so ringing tails survive.
|
||||
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 (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;
|
||||
|
||||
// Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap
|
||||
// is immutable after construction, and under reloadMutex_ nobody can free `cur`.
|
||||
Keymap km = cur->keymap;
|
||||
auto built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
publishBuiltLocked(std::move(built));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::retireIdleDrain() {
|
||||
// Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or
|
||||
// it still sounds" — the common case costs one relaxed load and no mutex.
|
||||
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
||||
if (idleGen == 0) return;
|
||||
std::lock_guard<std::mutex> 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
|
||||
// (about an already-evicted, older drain) can never match the newer occupant's
|
||||
// installedAt — the slot is monotone in generation — so a mid-swap race is closed by
|
||||
// this identity check, not by timing.
|
||||
if (!drain || drain->installedAt != idleGen) return;
|
||||
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 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);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
[seen](const std::unique_ptr<LoadedInstrument>& e) {
|
||||
return e->installedAt < seen;
|
||||
}),
|
||||
graveyard_.end());
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
|
||||
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
|
||||
// paying for a full reload. Once concluded, the steady state is this 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(), performanceMap()));
|
||||
if (decision == LegacyLiftDecision::Stale) {
|
||||
// Provably stale (the bank parses and knows none of the referenced ids): give up
|
||||
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
|
||||
// and the genChanged reload refreshes the refs without consulting 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 (the editor's UI timer calls this). Both reads allocate and call
|
||||
// REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER
|
||||
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
||||
BankSyncResult result;
|
||||
|
||||
// Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer
|
||||
// cadence that drives reloads — an edited-away instrument stops costing memory as soon
|
||||
// as its tails die instead of squatting in the drain slot until the next reload.
|
||||
retireIdleDrain();
|
||||
|
||||
// --- S8: assignment-request consume FIRST -------------------------------------
|
||||
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
||||
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
||||
// the sampleId names an existing sample (the reader requirement — an unresolvable pair is
|
||||
// dropped). Then run the pure consume decision against this instance's persisted marker.
|
||||
std::optional<AssignmentRequest> request;
|
||||
if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
|
||||
request = decodeAssignmentRequest(*raw);
|
||||
}
|
||||
|
||||
bool resolves = false;
|
||||
if (request) {
|
||||
// Resolve the assigned sample 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 lastConsumed and conditionally write it back under a single lock scope so there
|
||||
// is no interleave window between the read and the write (a concurrent getState could
|
||||
// otherwise observe a stale marker between the two separate lock acquisitions).
|
||||
std::int64_t lastConsumed = 0;
|
||||
const AssignConsumeDecision decision = [&] {
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumed = lastConsumedAssignGeneration_;
|
||||
const AssignConsumeDecision d =
|
||||
consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
|
||||
// Advance the persisted consumed marker whenever the decision consumed the request
|
||||
// (applied OR dropped-as-seen). getState will persist it on the next project save so
|
||||
// a re-open does not re-apply. A non-target instance leaves the marker (decision
|
||||
// returns it unchanged) so it stays eligible if focus later lands here.
|
||||
if (d.consumedGeneration != lastConsumed) {
|
||||
lastConsumedAssignGeneration_ = d.consumedGeneration;
|
||||
}
|
||||
return d;
|
||||
}();
|
||||
|
||||
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. 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
|
||||
// materialized for the previously loaded sample would shadow the assigned pick under
|
||||
// first-match resolve. Authored maps (any narrow key range) are untouched.
|
||||
PerformanceMap reconciled = performanceMap();
|
||||
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
|
||||
setPerformanceMap(reconciled);
|
||||
}
|
||||
result.applied = true;
|
||||
}
|
||||
|
||||
// --- 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 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;
|
||||
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): 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 bank that is not readable YET retries a cheap null publish on the
|
||||
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
|
||||
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
|
||||
// nothing to lift, so the lift concludes permanently instead of churning a full bank
|
||||
// read + reload every tick forever. 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()) {
|
||||
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
legacyLift = hasIntent && legacyLiftShouldRun();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
Reference in New Issue
Block a user