Merge ps-w9-t1-sync: S9 bank-generation change-detection + assignment reader

This commit is contained in:
2026-07-27 00:05:22 -04:00
21 changed files with 868 additions and 36 deletions
+70
View File
@@ -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
+105
View File
@@ -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
+50
View File
@@ -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:
+9
View File
@@ -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();
+40 -3
View File
@@ -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).
+13
View File
@@ -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_;
+88 -1
View File
@@ -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-
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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}.