Merge ps-w9-t1-sync: S9 bank-generation change-detection + assignment reader
This commit is contained in:
+18
-5
@@ -645,7 +645,9 @@ void doBankDelete() {
|
||||
ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n");
|
||||
return;
|
||||
}
|
||||
persistBankOp("ReaSampler: delete bank");
|
||||
// S9: bump only when the deleted bank held samples — dropping them changes what a live
|
||||
// instance referencing one could play. Deleting an EMPTY bank is purely organizational.
|
||||
persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0);
|
||||
}
|
||||
|
||||
// Evacuate a named bank: move every member back to the pool (index-only, collapse by
|
||||
@@ -666,7 +668,8 @@ void doBankEvacuate() {
|
||||
"destination, not a source).\n");
|
||||
return;
|
||||
}
|
||||
persistBankOp("ReaSampler: evacuate bank");
|
||||
// S9: evacuate moves members between banks (bank membership changes) -> bump.
|
||||
persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true);
|
||||
}
|
||||
|
||||
// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool),
|
||||
@@ -752,7 +755,9 @@ void doBankTransferSelected(bool copy) {
|
||||
if (mutated) {
|
||||
const std::string label =
|
||||
std::string("ReaSampler: ") + verb + " sample(s)";
|
||||
persistBankOp(label.c_str());
|
||||
// S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an
|
||||
// instance may reference) -> bump so assigned instances refresh hands-free.
|
||||
persistBankOp(label.c_str(), /*bumpGeneration=*/true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,7 +799,9 @@ void doBankRemoveSelected() {
|
||||
}
|
||||
|
||||
// No-op guardrail (R-B): open an undo point only if the index actually mutated.
|
||||
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
|
||||
// S9: a remove drops a sample from a bank (an instance referencing it must refresh — it
|
||||
// will resolve to silence, per the stale-id policy) -> bump.
|
||||
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true);
|
||||
}
|
||||
|
||||
// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE
|
||||
@@ -884,8 +891,14 @@ void doBankPruneFolder() {
|
||||
// change stands and persists on the user's next save; it just earns no undo point until
|
||||
// there is a project to persist into (undo of an unsaved bank op has nothing to roll
|
||||
// back to anyway). The Begin/End must still be balanced, hence the close-either-way.
|
||||
void persistBankOp(const char* label) {
|
||||
void persistBankOp(const char* label, bool bumpGeneration) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the
|
||||
// fresh generation rides the same ext-state write the persist makes (persistBook() ->
|
||||
// saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs
|
||||
// (the caller decides); a pure-organizational verb passes false and leaves the counter be,
|
||||
// so a rename/activate does not needlessly refresh live instances.
|
||||
if (bumpGeneration) g_session->bumpBankGeneration();
|
||||
const bool persisted = persistBook();
|
||||
if (persisted)
|
||||
Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG);
|
||||
|
||||
+9
-1
@@ -79,6 +79,14 @@ int bankPruneCommandId();
|
||||
// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate
|
||||
// name, un-deletable pool, etc.) must return before reaching here so no empty undo
|
||||
// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook().
|
||||
void persistBankOp(const char* label);
|
||||
//
|
||||
// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live
|
||||
// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left,
|
||||
// arrived, or dropped out of a bank an instance may reference). Leave it false (the default)
|
||||
// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no
|
||||
// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when
|
||||
// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the
|
||||
// same ext-state write and undo captures the pre/post generation with the rest of the blob.
|
||||
void persistBankOp(const char* label, bool bumpGeneration = false);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
+7
-4
@@ -2059,7 +2059,10 @@ void doDeleteBank(const std::string& bankId) {
|
||||
// r == 6 (Yes) falls through to a plain delete (drops members).
|
||||
}
|
||||
if (!book()->deleteBank(bankId)) return;
|
||||
persistBankOp("ReaSampler: delete bank");
|
||||
// S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then-
|
||||
// delete path moved/dropped members) — both change what a live instance could play. An
|
||||
// empty-bank delete is purely organizational, no bump.
|
||||
persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0);
|
||||
// shownBankId is reconciled by the next fingerprint pass. If no named banks remain,
|
||||
// nudge focus to the pool so the selection has a valid home.
|
||||
if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool;
|
||||
@@ -2071,7 +2074,7 @@ void doEvacuateBank(const std::string& bankId) {
|
||||
const Bank* bk = book()->bank(bankId);
|
||||
if (!bk || bk->isPool()) return;
|
||||
if (!book()->evacuate(bankId)) return;
|
||||
persistBankOp("ReaSampler: evacuate bank");
|
||||
persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
@@ -2115,7 +2118,7 @@ void transferSamples(const std::vector<std::string>& sampleIds,
|
||||
if (!mutated) return; // nothing changed — no persist, no undo point
|
||||
|
||||
const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)";
|
||||
persistBankOp(label);
|
||||
persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed
|
||||
// The selection indexed into the source; after a move those indices are stale, so
|
||||
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||
g_panel.selection = Selection{};
|
||||
@@ -2140,7 +2143,7 @@ void removeSamples(const std::vector<std::string>& sampleIds,
|
||||
++removed;
|
||||
if (removed == 0) return; // nothing changed — no persist, no undo point
|
||||
|
||||
persistBankOp("ReaSampler: remove sample(s)");
|
||||
persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped
|
||||
// The selection indexed into the source; after a remove those indices are stale, so
|
||||
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||
g_panel.selection = Selection{};
|
||||
|
||||
@@ -45,6 +45,19 @@ inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||
// The per-project minted-GUID identity key.
|
||||
inline constexpr const char* kProjExtGuidKey = "project_guid";
|
||||
|
||||
// The S9 BANK-GENERATION key. The EXTENSION stamps a monotonic decimal counter here that it
|
||||
// bumps on every bank-content mutation that changes what a live instance would PLAY (capture
|
||||
// add, re-capture-in-place, sample remove, move/copy affecting banks, ingest import). The VST3
|
||||
// instrument READS it off the audio thread on a UI-timer cadence and, when the value differs
|
||||
// from what it last saw, calls reloadFromBank() so a recapture/ingest refreshes playing
|
||||
// instances hands-free (the S9 change-detection trigger). WIRE-SHARED (instrument reads it);
|
||||
// the instrument never WRITES it (the extension owns it, same read-only-over-bank rule as the
|
||||
// assignment request). Additive to the persist blob — an absent stamp reads as generation 0
|
||||
// (a pre-S9 project), and the first bump (>= 1) then reads as a change. FOREVER-STABLE once
|
||||
// shipped: changing this spelling resets every already-shipped instance's change-detection
|
||||
// baseline (a one-time spurious reload), so it is fixed like every sibling key.
|
||||
inline constexpr const char* kProjExtBankGenKey = "bank_generation";
|
||||
|
||||
// The S8 ingest ASSIGNMENT-REQUEST key. The EXTENSION writes an assignment request here
|
||||
// after an ingest-with-assign (arrange capture / Media-Explorer import / drop-onto-panel):
|
||||
// "the active sampler instance should now play THIS sample." The value is the pure
|
||||
|
||||
@@ -458,6 +458,10 @@ void doImportFromMediaExplorer() {
|
||||
// zero flag so REAPER discards the undo entry (the house pattern from actions.cpp).
|
||||
if (r.added) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: an ingest import adds a sample to the active bank -> bump inside the block so
|
||||
// the stamped generation refreshes the assigned instance hands-free (and undo rolls
|
||||
// the generation back with the banks/assign_request keys).
|
||||
g_session->bumpBankGeneration();
|
||||
const bool persisted = g_session->saveToActiveProject();
|
||||
// Assign request inside the same block: undo rolls back both keys together.
|
||||
ingestAssignActiveInstance(g_session->book().activeBankId(), r.sampleId);
|
||||
@@ -531,6 +535,9 @@ void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
|
||||
if (!firstAssignId.empty()) {
|
||||
if (importedNew > 0) {
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// S9: one coalesced bump for the whole drop (>=1 new sample landed) inside the
|
||||
// block so the generation refreshes the assigned instance and undo rolls it back.
|
||||
g_session->bumpBankGeneration();
|
||||
const bool persisted = g_session->saveToActiveProject();
|
||||
// Assign inside the block: undo restores both keys atomically.
|
||||
ingestAssignActiveInstance(firstAssignBank, firstAssignId);
|
||||
|
||||
+22
-4
@@ -216,7 +216,10 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res)
|
||||
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
|
||||
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
|
||||
g_session.owned().add(res.sample.relativePath);
|
||||
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
|
||||
// S9: a capture add changes what a live instance could play (a new sample landed in the
|
||||
// active bank) -> bump before the persist so the stamped generation refreshes instances.
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp)
|
||||
}
|
||||
|
||||
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
|
||||
@@ -852,6 +855,9 @@ static std::string RunCapture(const reasampler::CaptureActionDef& def)
|
||||
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
|
||||
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
||||
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
||||
// S9: a capture add is a bank-content change -> bump before the persist so an assigned
|
||||
// live instance refreshes hands-free (the S8 capture+assign path builds on this).
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject();
|
||||
|
||||
// Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne
|
||||
@@ -1042,8 +1048,12 @@ static void RunBatchCaptureItems()
|
||||
} // selGuard restores the original selection here, on every path
|
||||
|
||||
// Persist ONCE for the whole batch (one ext-state write) — only if something landed.
|
||||
if (anyAdded)
|
||||
// S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample,
|
||||
// so a single increment past the last-seen value is enough to trigger one instance reload.
|
||||
if (anyAdded) {
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject();
|
||||
}
|
||||
|
||||
ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
|
||||
}
|
||||
@@ -1159,8 +1169,11 @@ static void RunBatchCaptureRazor()
|
||||
}
|
||||
} // selGuard restores the original track selection here, on every path
|
||||
|
||||
if (anyAdded)
|
||||
// S9: one coalesced bump for the whole razor batch (see the item-batch note above).
|
||||
if (anyAdded) {
|
||||
g_session.bumpBankGeneration();
|
||||
g_session.saveToActiveProject();
|
||||
}
|
||||
|
||||
ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
|
||||
}
|
||||
@@ -1344,7 +1357,12 @@ static void RunRecaptureFromSource()
|
||||
// Record the regenerated file in the owned manifest (a new file the tool wrote);
|
||||
// the superseded old file becomes an orphan reclaimed by Phase R prune.
|
||||
g_session.owned().add(updated.relativePath);
|
||||
const bool persisted = g_session.saveToActiveProject(); // book + manifest + MarkProjectDirty
|
||||
// S9: re-capture-in-place regenerates the SAME id's audio — the exact case the
|
||||
// hands-free refresh exists for (an instance referencing this id keeps playing the
|
||||
// OLD audio until it reloads). Bump inside the undo block so undo rolls back the
|
||||
// generation with the rest of the blob.
|
||||
g_session.bumpBankGeneration();
|
||||
const bool persisted = g_session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
|
||||
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
|
||||
persisted ? UNDO_STATE_MISCCFG : 0);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
#include "app_version.h"
|
||||
#include "capture_paths.h"
|
||||
#include "prune_reconcile.h"
|
||||
#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
@@ -252,6 +253,16 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey, stampVersion().c_str());
|
||||
|
||||
// S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
|
||||
// seam so the counter and MarkProjectDirty stay paired. The value is whatever
|
||||
// bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
|
||||
// every content mutation's own save carries the fresh generation the instrument reads. The
|
||||
// format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree
|
||||
// byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey,
|
||||
vst::formatBankGeneration(bankGeneration_).c_str());
|
||||
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
@@ -580,6 +591,16 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
kProjExtVersionKey)
|
||||
: std::string{});
|
||||
|
||||
// S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
|
||||
// writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
|
||||
// rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
|
||||
// project switch reads THAT project's counter, not the previous one's; an absent/malformed
|
||||
// stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
|
||||
bankGeneration_ = vst::parseBankGeneration(
|
||||
proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey)
|
||||
: std::string{});
|
||||
|
||||
if (!proj) {
|
||||
book_ = BankBook{};
|
||||
return;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip
|
||||
// and capture_paths (pure) for the path arithmetic it drives.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "app_version.h"
|
||||
@@ -149,6 +150,24 @@ public:
|
||||
// reason about the origin build without re-reading ext state.
|
||||
const WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
// The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic
|
||||
// per project: recovered on load (so it continues from the stored value rather than
|
||||
// resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on
|
||||
// every saveToActiveProject(). Exposed const for the writer sites to read/log.
|
||||
std::int64_t bankGeneration() const { return bankGeneration_; }
|
||||
|
||||
// Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes
|
||||
// what a live instance would PLAY (capture add, re-capture-in-place, sample remove,
|
||||
// move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create /
|
||||
// rename / activate / reorder a bank), which change no existing (bankId, sampleId) ->
|
||||
// content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call
|
||||
// the same mutation already makes (the counter rides the persist blob, so there is no
|
||||
// separate write). In-memory only here — cheap and REAPER-free; the persist is the write.
|
||||
// Over-bumping is safe (a reload that finds unchanged content atomically re-installs the
|
||||
// same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err
|
||||
// toward bumping. Idempotent per logical op — call once per mutation, before the persist.
|
||||
void bumpBankGeneration() { ++bankGeneration_; }
|
||||
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
@@ -282,6 +301,14 @@ private:
|
||||
// the previous project's stamp. Read-only to consumers via writingVersion().
|
||||
WritingVersion writingVersion_;
|
||||
|
||||
// The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path
|
||||
// from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it
|
||||
// continues monotonic from the persisted value across reopen and resets cleanly on a
|
||||
// project switch (a different project's counter, not the previous project's). bumped by
|
||||
// bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject().
|
||||
// Default 0 for an unsaved / never-loaded / pre-S9 session.
|
||||
std::int64_t bankGeneration_ = 0;
|
||||
|
||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// bank_sync.cpp — see bank_sync.h. Pure; standard library only.
|
||||
|
||||
#include "bank_sync.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
std::int64_t parseBankGeneration(const std::string& raw) {
|
||||
if (raw.empty()) return kBankGenerationAbsent;
|
||||
|
||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises.
|
||||
// A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max
|
||||
// all reject to the absent default (0). Manual accumulation with an overflow guard so a
|
||||
// pathologically long digit run can never wrap into a bogus small value.
|
||||
std::int64_t value = 0;
|
||||
constexpr std::int64_t kMax = std::numeric_limits<std::int64_t>::max();
|
||||
for (const char c : raw) {
|
||||
if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole
|
||||
const int digit = c - '0';
|
||||
// Guard value*10 + digit against overflow before performing it.
|
||||
if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject
|
||||
value = value * 10 + digit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string formatBankGeneration(std::int64_t generation) {
|
||||
// Non-negative decimal; a negative (should never be produced by the writer) formats as
|
||||
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
|
||||
// stays in the >= 0 domain by construction.
|
||||
return std::to_string(generation);
|
||||
}
|
||||
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current) {
|
||||
return current != seen;
|
||||
}
|
||||
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget) {
|
||||
AssignConsumeDecision d;
|
||||
d.consumedGeneration = lastConsumed; // default: nothing changes
|
||||
|
||||
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
|
||||
if (!request) return d;
|
||||
if (request->generation <= lastConsumed) return d;
|
||||
|
||||
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
|
||||
// advance the marker (stay eligible if focus later lands here). No thundering herd.
|
||||
if (!isFocusedTarget) return d;
|
||||
|
||||
// The request is new AND we are the target: it will be consumed-as-seen either way, so
|
||||
// advance the marker to its generation so it is never re-evaluated.
|
||||
d.consumedGeneration = request->generation;
|
||||
|
||||
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
|
||||
// advanced above, but no selection change.
|
||||
if (!resolves) return d;
|
||||
|
||||
// Rule 4: new, target, resolvable -> apply the selection.
|
||||
d.apply = true;
|
||||
d.bankId = request->bankId;
|
||||
d.sampleId = request->sampleId;
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
|
||||
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
|
||||
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
|
||||
// host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
|
||||
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
|
||||
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
|
||||
// read crosses the bridge in the shell; every DECISION after — parse the generation
|
||||
// stamp, decide whether it differs from what we last saw, decide whether a decoded
|
||||
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
||||
//
|
||||
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
||||
// effects (reloadFromBank, setSelectedSampleId); this module owns only the yes/no maths so
|
||||
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
||||
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
|
||||
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
|
||||
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
|
||||
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
|
||||
inline constexpr std::int64_t kBankGenerationAbsent = 0;
|
||||
|
||||
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
|
||||
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
|
||||
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
|
||||
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
|
||||
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
|
||||
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
|
||||
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
|
||||
// poll once the value is clean).
|
||||
std::int64_t parseBankGeneration(const std::string& raw);
|
||||
|
||||
// Format a bank-generation counter for the ext-state stamp. The inverse of
|
||||
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
|
||||
// the stamp is byte-stable across writes of the same value.
|
||||
std::string formatBankGeneration(std::int64_t generation);
|
||||
|
||||
// Has the bank generation changed since the reader last saw `seen`? True when `current`
|
||||
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
|
||||
// an increase): the writer is monotonic, but a project switch or reload can legitimately
|
||||
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
|
||||
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
|
||||
// first-bump refresh the spec requires).
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
|
||||
|
||||
// The verdict of the S8 assignment-request consume decision (below). A pure value the
|
||||
// processor shell acts on: apply the selection (or not) and advance the consumed marker
|
||||
// (or not). Distinct booleans because the two are NOT the same event — a request may be
|
||||
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
|
||||
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
|
||||
// every poll.
|
||||
struct AssignConsumeDecision {
|
||||
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
|
||||
std::string bankId; // the request's bank (valid only when apply)
|
||||
std::string sampleId; // the request's sample (valid only when apply)
|
||||
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
|
||||
};
|
||||
|
||||
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
|
||||
//
|
||||
// `request` — the decoded assignment request (nullopt when the assign_request key
|
||||
// is absent / malformed — nothing pending).
|
||||
// `lastConsumed` — the generation this instance last consumed (persisted in component
|
||||
// state so a re-open does not re-apply a request the user already got,
|
||||
// then manually changed away from). Defaults to 0 for a fresh instance.
|
||||
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
|
||||
// sample RIGHT NOW (the shell computed this against the live bank blob).
|
||||
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
|
||||
// thundering-herd policy (e.g. only the focused-editor instance applies).
|
||||
// The shell passes true when this instance should act; false suppresses
|
||||
// consumption entirely so a non-target instance neither applies nor
|
||||
// advances its marker (it stays eligible if it later becomes the target).
|
||||
//
|
||||
// RULES (all pure, order matters):
|
||||
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
|
||||
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
|
||||
// request's generation, so it is not re-applied.)
|
||||
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
|
||||
// apply and do NOT advance the marker — a non-target instance must stay able to consume
|
||||
// the request if focus later lands on it. (No thundering herd: only the target acts.)
|
||||
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
|
||||
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
|
||||
// but DO advance the marker to the request's generation so a stale/unresolvable request
|
||||
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
|
||||
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
||||
// advance the marker to the request's generation.
|
||||
//
|
||||
// The shell then: if apply, setSelectedSampleId + reloadFromBank; always persist
|
||||
// consumedGeneration into component state when it advanced.
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -38,6 +38,14 @@ namespace {
|
||||
#ifdef _WIN32
|
||||
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
||||
|
||||
// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
|
||||
// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence"
|
||||
// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many
|
||||
// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough
|
||||
// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero).
|
||||
constexpr UINT_PTR kSyncTimerId = 1;
|
||||
constexpr UINT kSyncTimerIntervalMs = 500;
|
||||
|
||||
// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry
|
||||
// is in capture_browser / keyboard_strip). The title band names the plugin + a live
|
||||
// readout; the toggle band carries the Browser/Zones switch; the setup band (single-
|
||||
@@ -174,6 +182,35 @@ void ReaSamplerEditor::rebuildVisible() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared
|
||||
// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes
|
||||
// Windows the only build target, but the TU must still compile elsewhere).
|
||||
void ReaSamplerEditor::onSyncTimer() {
|
||||
// UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the
|
||||
// processor (off the audio thread — the poll itself never touches process()). NEVER while a
|
||||
// drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the
|
||||
// user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release.
|
||||
if (!processor_) return;
|
||||
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
|
||||
|
||||
// An open editor marks THIS instance the focused assignment target (the thundering-herd
|
||||
// policy — only an editor-open instance applies a pending assign; see the handoff). Pass
|
||||
// true so this instance consumes the request; instances with no editor open do not poll at
|
||||
// all (the timer is bound to the child window), so they never contend for the request.
|
||||
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
|
||||
|
||||
// Re-snapshot the editor's own view only when something changed (a reload from a bank
|
||||
// content change, or an applied assignment). refreshFromBank re-reads the bank blob + the
|
||||
// processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM
|
||||
// caches, then repaints — so the browser + setup surface reflect the new bank hands-free.
|
||||
if (r.reloaded || r.applied) {
|
||||
refreshFromBank();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
void ReaSamplerEditor::commitAndReload() {
|
||||
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
|
||||
// the instrument off the audio thread (reloadFromBank bakes them into the live Keymap).
|
||||
@@ -373,11 +410,21 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
||||
if (childHwnd_) {
|
||||
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
||||
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
|
||||
// lifetime — created here, killed in removedFromParent — so an instance whose editor
|
||||
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
|
||||
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
|
||||
// Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor
|
||||
// was closed) or a bank change applies the instant the editor opens, rather than waiting
|
||||
// up to one timer interval. refreshFromBank above already primed the view; this folds in
|
||||
// any pending assign/generation so the just-opened editor shows the assigned capture.
|
||||
onSyncTimer();
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::removedFromParent() {
|
||||
if (childHwnd_) {
|
||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||
DestroyWindow(childHwnd_);
|
||||
childHwnd_ = nullptr;
|
||||
}
|
||||
@@ -1064,6 +1111,9 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
self->invalidate();
|
||||
}
|
||||
return 0;
|
||||
case WM_TIMER:
|
||||
if (self && wParam == kSyncTimerId) self->onSyncTimer();
|
||||
return 0;
|
||||
case WM_ERASEBKGND:
|
||||
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
|
||||
default:
|
||||
|
||||
@@ -88,6 +88,15 @@ private:
|
||||
void onMouseMove(int x, int y);
|
||||
void onMouseUp(int x, int y);
|
||||
|
||||
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
||||
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
||||
// a new assignment request -> apply as this instance's selection) and, when anything
|
||||
// changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser /
|
||||
// setup surface reflect the new bank. An open editor means THIS instance is the focused
|
||||
// assignment target (the thundering-herd policy — see the handoff), so it passes true.
|
||||
// Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface.
|
||||
void onSyncTimer();
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
void invalidate();
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh)
|
||||
#include "editor_geometry.h" // Rect (shared with embed_strip)
|
||||
#include "embed_strip.h" // the pure strip layout + hit-test
|
||||
#include "ext_keys.h" // kProjExtBanksKey
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey
|
||||
#include "reaper_bridge.h"
|
||||
#include "reasampler_processor.h"
|
||||
|
||||
@@ -91,6 +92,40 @@ void ReaSamplerEmbed::refresh() {
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::maybeRefresh() {
|
||||
if (!processor_) { refresh(); return; } // clears state; cheap
|
||||
|
||||
// The performance map is a cheap in-process accessor (mutex + copy), and the editor may
|
||||
// have edited zones with NO bank-content change — always re-snapshot it so a zone edit
|
||||
// reflects immediately.
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
|
||||
// The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank-
|
||||
// generation stamp (a small ext-state read): only re-read the bank when the generation
|
||||
// changed since the last paint (a recapture / ingest / remove), or on the first paint
|
||||
// (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint
|
||||
// folds it and subsequent idle paints skip the bank read entirely.
|
||||
std::int64_t currentGen = lastSeenBankGeneration_;
|
||||
if (auto rawGen =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
} else if (lastSeenBankGeneration_ < 0) {
|
||||
currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read
|
||||
}
|
||||
// Intentional asymmetry: a TRANSIENT bridge failure (readReasamplerExtState returned
|
||||
// nullopt after we were already primed) leaves currentGen == lastSeenBankGeneration_,
|
||||
// so the bank-blob read is skipped and the editor keeps its last-known sample list.
|
||||
// A stale-but-intact list is better than clearing samples_ on every transient hiccup.
|
||||
|
||||
if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) {
|
||||
auto banks =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
lastSeenBankGeneration_ = currentGen;
|
||||
}
|
||||
}
|
||||
|
||||
TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
switch (msg) {
|
||||
case REAPER_FXEMBED_WM_IS_SUPPORTED:
|
||||
@@ -141,8 +176,10 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
// Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank
|
||||
// changes without its own timer — REAPER repaints the embed surface on its cadence.
|
||||
refresh();
|
||||
// changes without its own timer — REAPER repaints the embed surface on its cadence. S9
|
||||
// dirty-guard: maybeRefresh does the EXPENSIVE bank-blob read only when the bank generation
|
||||
// changed (the flagged S6 follow-up), always refreshing the cheap performance map.
|
||||
maybeRefresh();
|
||||
|
||||
// REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike
|
||||
// the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0).
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -86,7 +87,19 @@ private:
|
||||
// as the editor's refreshSampleList does (bridge read + processor accessors, UI thread).
|
||||
void refresh();
|
||||
|
||||
// The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank-
|
||||
// generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the
|
||||
// generation changed since the last paint (or on the first paint) — the strip re-read
|
||||
// per paint was wasteful now that a generation counter exists. The performance map (a
|
||||
// cheap in-process accessor, edited by the editor independently of bank content) is
|
||||
// ALWAYS refreshed so a zone edit still reflects immediately. UI thread only.
|
||||
void maybeRefresh();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
// The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first
|
||||
// maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration
|
||||
// yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0).
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
// Snapshotted for the current paint (refreshed each paint off the audio thread).
|
||||
std::vector<SampleChoice> samples_;
|
||||
PerformanceMap map_;
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
|
||||
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
|
||||
|
||||
#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
|
||||
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "ext_keys.h" // kProjExtBanksKey (shared wire contract)
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract)
|
||||
#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
|
||||
@@ -184,6 +186,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
const ComponentState cs = deserializeComponentState(bytes);
|
||||
setSelectedSampleId(cs.selectionId);
|
||||
setPerformanceMap(cs.map);
|
||||
// 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).
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
|
||||
}
|
||||
// Restore the S7 channel mode and point the output bus at its arrangement so a reopened
|
||||
// project comes back in the saved mode. setState runs before the host queries bus info, so
|
||||
// seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent.
|
||||
@@ -208,6 +216,10 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
state_out.selectionId = selectedSampleId();
|
||||
state_out.map = performanceMap();
|
||||
state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
||||
}
|
||||
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()),
|
||||
@@ -392,6 +404,81 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// --- 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. reloadFromBank below
|
||||
// rebuilds against the new selection, so skip a redundant reload here.
|
||||
setSelectedSampleId(decision.sampleId);
|
||||
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 current bank, 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;
|
||||
|
||||
if (genChanged || result.applied) {
|
||||
reloadFromBank(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
|
||||
result.reloaded = genChanged; // report S9 vs S8 distinctly for the editor's reaction
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
|
||||
// whole block (a single atomic acquire), then publish inst->installedAt so the off-
|
||||
|
||||
@@ -122,6 +122,27 @@ public:
|
||||
// resolved selection id ("" if nothing was loaded) for the editor to reflect.
|
||||
std::string reloadFromBank();
|
||||
|
||||
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
|
||||
// can react (repaint / re-snapshot its own view) only when something actually changed.
|
||||
struct BankSyncResult {
|
||||
bool reloaded = false; // the bank generation changed -> reloadFromBank ran
|
||||
bool applied = false; // a new assignment request was applied -> selection changed
|
||||
};
|
||||
|
||||
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
|
||||
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). Semantics:
|
||||
// * S9: if the bank generation differs from what we last saw, call reloadFromBank() so a
|
||||
// recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
|
||||
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
|
||||
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
|
||||
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
|
||||
// a non-target instance neither applies nor advances its marker.
|
||||
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
||||
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
||||
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
||||
// Idempotent on an idle tick (generation unchanged + no new request -> no work).
|
||||
BankSyncResult pollBankSync(bool isFocusedTarget);
|
||||
|
||||
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
@@ -210,6 +231,22 @@ private:
|
||||
std::mutex channelModeMutex_;
|
||||
ChannelMode channelMode_ = ChannelMode::Mono;
|
||||
|
||||
// The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in
|
||||
// component state (v5) so a re-open does not re-apply a request the user already got and
|
||||
// then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by
|
||||
// pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER
|
||||
// read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies.
|
||||
std::mutex assignMarkerMutex_;
|
||||
std::int64_t lastConsumedAssignGeneration_ = 0;
|
||||
|
||||
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
|
||||
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
|
||||
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
|
||||
// FIRST poll after an editor open BASELINES the seen value without a redundant reload (setState
|
||||
// already loaded the current bank); a subsequent generation CHANGE then drives the reload.
|
||||
// NOT read on the audio thread.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
|
||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||
// off-thread only.
|
||||
double sampleRate_ = 44100.0;
|
||||
|
||||
+23
-3
@@ -406,6 +406,10 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
putU32le(out, kComponentStateVersion);
|
||||
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
|
||||
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
|
||||
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
|
||||
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
||||
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
||||
putU64le(out, asU64(state.lastConsumedAssignGeneration));
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
@@ -447,15 +451,31 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map);
|
||||
return out; // channelMode stays Mono (pre-S7)
|
||||
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
||||
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
|
||||
// defaults to 0, so a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map);
|
||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
||||
}
|
||||
if (version != kComponentStateVersion) return out; // unknown -> empty
|
||||
|
||||
// v4: the channel-mode byte precedes the v3 body. A non-{0,1} byte is treated as mono
|
||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||
// v5: the channel-mode byte, then the 8-byte consumed-assignment marker, precede the v3
|
||||
// body. A non-{0,1} mode byte is treated as mono (conservative default) rather than
|
||||
// rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
out.lastConsumedAssignGeneration = r.i64();
|
||||
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
|
||||
+28
-13
@@ -285,25 +285,40 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
//
|
||||
// Format (v4): 4-byte LE version tag (== 4), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then a 4-byte LE selection-id length + id bytes, then the v2 zones payload
|
||||
// (4-byte LE zone count + per-zone records, identical to serializePerformance's body). The
|
||||
// channel-mode field is the ONLY v4 addition over v3 — the envelope grew a field, the zones
|
||||
// payload is untouched (a PARALLEL track owns zone-record extension under the map's own
|
||||
// versioning). BACK-COMPAT on read (every older blob lifts to channelMode = MONO, preserving
|
||||
// current behavior for already-saved instances):
|
||||
// * v4 blob -> {channelMode, selectionId, zones} parsed directly.
|
||||
// * v3 blob -> {mono, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
// Format (v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker),
|
||||
// then a 4-byte LE selection-id length + id bytes, then the v2 zones payload (4-byte LE zone
|
||||
// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is
|
||||
// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched
|
||||
// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on
|
||||
// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
|
||||
// preserving current behavior for already-saved instances):
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
|
||||
// * v4 blob -> {channelMode, 0, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||
// * v3 blob -> {mono, 0, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, 0, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E)
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 4;
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 5;
|
||||
|
||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
|
||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
||||
|
||||
Reference in New Issue
Block a user