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:
+151
-237
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user