Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal

The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew.
This commit is contained in:
2026-08-02 13:15:47 -04:00
parent 5c6525fb91
commit da14509ab5
15 changed files with 218 additions and 76 deletions
+18 -15
View File
@@ -1,8 +1,8 @@
// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold
// into the two windows the UI drains, and the drain that starts the next window. The ballistics
// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio
// thread's per-block path. Templated on the accumulator ONLY so the drain-inside-the-fold
// interleave below can be pinned deterministically instead of raced for.
// thread's per-block path. The folds are templated on the accumulator ONLY so the
// drain-inside-the-fold interleave below can be pinned deterministically instead of raced for.
#pragma once
@@ -30,36 +30,39 @@ inline constexpr float kMeterGainIdentity = 1.f;
// would then drop the block outright: it decided against storing by comparing with a window the
// UI has since taken, so that block's reading enters neither the old window nor the new one.
// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on
// exit however the two interleave. Bounded the audio thread is this accumulator's only other
// writer, so one interfering consume costs one retry. Relaxed throughout: the accumulators are
// advisory and order no other state. Block rate, never per frame.
// exit however the two interleave. STRONG, so the loop is bounded by the interference it is
// written against: the audio thread is the only writer besides the UI's single consume, and
// weak's permitted spurious failure would make an unbounded retry count reachable with no
// interference at all. Three calls per block, so the strong form costs nothing measurable.
// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is
// templated only so a test can pin the interleave; it must behave as std::atomic<float>.
template <class Accumulator>
inline void foldPeak(Accumulator& acc, float blockPeak) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_weak(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
template <class Accumulator>
inline void foldMinGain(Accumulator& acc, float blockMinGain) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_weak(seen, seen < blockMinGain ? seen : blockMinGain,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
// Takes what the window accumulated and reinstalls the identity element, which IS what starts
// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who).
template <class Accumulator>
inline float consumePeak(Accumulator& acc) {
// Concrete: only the folds have the interleave a test seam buys, and a template over one
// instantiation models nothing.
inline float consumePeak(std::atomic<float>& acc) {
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
}
template <class Accumulator>
inline float consumeMinGain(Accumulator& acc) {
inline float consumeMinGain(std::atomic<float>& acc) {
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
}
+6
View File
@@ -104,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
}
}
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
+7
View File
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// True when two refs would build the same SampleData: path plus every intrinsic
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
// what that sample was decoded from; comparing the fields at the call site instead would go
// stale the first time this struct gains one.
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
+4 -2
View File
@@ -91,8 +91,10 @@ bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the
// spec asks only for "a small floor". Raising it hides genuine catches, since the limiter's
// ceiling is only 0.3 dBTP.
// spec asks only for "a small floor". It is bounded on BOTH sides: raising it hides genuine
// catches, since the limiter's ceiling is only 0.3 dBTP; lowering it turns the lamp into a
// "some sample crossed the ceiling" light, because the gain law is ceiling/peak and so reports
// an arbitrarily small reduction for a peak arbitrarily close to the ceiling.
inline constexpr double kGrLampFloorDb = 0.5;
bool grLampLit(const MasterMeterUi& m);
+1 -1
View File
@@ -107,7 +107,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. Nothing parked means nothing was decoded, which routes the activation back through the full reload; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
+39 -4
View File
@@ -268,8 +268,7 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
}
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the
// one safety-critical swap dance (see the header's drain-slot proof).
// REQUIRES reloadMutex_ held (see the header's drain-slot proof).
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(),
@@ -347,9 +346,45 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// park. Rebuilding here would displace a correct instrument into the drain slot.
if (live_.load(std::memory_order_acquire)) return true;
if (!dormantSample_) return false;
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
// pollBankSync, the only other route to either of the two calls below, runs off the
// editor's sync tick and nothing else. Both are a GetProjExtState plus a parse — no disk
// and no decode, which is what lets them stay on a path whose whole point is skipping
// those two.
const std::string selId = selectedSampleId();
const std::vector<std::string> ids = referencedSampleIds(selId);
SampleRefs refs;
bool sourceMoved = false;
{
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) {
const SelectedSample* before = findRef(sampleRefs_, selId);
const std::optional<SelectedSample> was =
before ? std::optional<SelectedSample>(*before) : std::nullopt;
refreshRefsFromBank(sampleRefs_, *banksJson, ids);
const SelectedSample* now = findRef(sampleRefs_, selId);
sourceMoved = !was || !now || !sameDecodeSource(*was, *now);
}
refs = sampleRefs_;
}
// A recapture that landed while this instance was inactive makes the park the WRONG audio,
// and refreshing the refs without re-decoding would leave the table naming one file while
// the voices play another. Hand back to the full reload, which decodes the new one.
if (sourceMoved) return false;
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
// second copy of the PCM for the whole active lifetime.
publishBuiltLocked(buildInstrumentLocked(std::move(*dormantSample_)));
// second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
// throwing build leaves nothing to resume — the next activation then takes the reload
// rather than publishing an empty sample as permanent silence.
SampleData resumed = std::move(*dormantSample_);
dormantSample_.reset();
publishBuiltLocked(buildInstrumentLocked(std::move(resumed)));
// The prune-protection republish reloadInstrument owes on every publish: it is also what
// heals an rsusage_ key whose write failed when this instance last set its state.
publishUsage(refs, ids);
return true;
}
+12 -5
View File
@@ -87,8 +87,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
reloadInstrument();
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
// restart synchronously deactivates/reactivates, and our setActive(true) reloads — from the
// refs above, which are only fully restored once this function has run to here.
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
// against the refs above, which are only fully restored once this function has run to here.
flushLatencyRestart();
return kResultOk;
}
@@ -183,13 +183,20 @@ void ReaSamplerProcessor::flushLatencyRestart() {
// Latched BEFORE the call: a host that services the restart synchronously re-enters this
// object inside it, so the next commit must compare against the value the host is about to
// read, not against the one it held before.
latencyAnnounced_.store(limiterEnabled_.load(std::memory_order_relaxed),
std::memory_order_relaxed);
const bool previouslyAnnounced = latencyAnnounced_.exchange(
limiterEnabled_.load(std::memory_order_relaxed), std::memory_order_relaxed);
// The SDK requires this on the UI thread and answers getLatencySamples only after the host's
// own deactivate/reactivate — so the flag is long committed by the time the host asks. This
// is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged
// bus renegotiation (see initialize()); do not conflate.
componentHandler->restartComponent(kLatencyChanged);
if (componentHandler->restartComponent(kLatencyChanged) == kResultOk) return;
// A refused restart leaves the host's delay compensation on the OLD value, so the latch has
// to come back off it: announcing a value the host never took would let a later toggle BACK
// to that value arm nothing, stranding the host's view permanently. Re-armed instead, which
// costs one retry per drain in a host that always refuses. The SDK documents no refusal
// semantics, so whether any host returns non-kResultOk here is `[verify — DAW]`.
latencyAnnounced_.store(previouslyAnnounced, std::memory_order_relaxed);
latencyRestartPending_.store(true, std::memory_order_release);
}
void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
+11 -4
View File
@@ -87,11 +87,18 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Activation governs ONE thing — whether the audio thread may run. The decoded
// SampleData has its own lifetime and survives the cycle (dormantSample_); only the
// voice state is built and destroyed here. Main/UI-thread call.
//
// Neither branch is idempotent on its own — a repeated deactivate would park an empty
// optional over a still-valid sample, and a repeated activate would reset the limiter over
// a live delay line — and the SDK base is an empty stub that guards neither.
// `[verify — DAW]` whether any host actually repeats the call.
if (static_cast<bool>(state) == active_) return kResultOk;
active_ = static_cast<bool>(state);
if (state) {
// Rebuild the voices around the sample the deactivate parked — no bridge read, no WAV
// decode — so a host-driven cycle (a kLatencyChanged restart, an offline-render
// bracket) costs no I/O. With nothing to activate from, the full reload runs: it
// resolves + decodes from the instance-owned refs (no bank read, so it plays regardless
// Rebuild the voices around the sample the deactivate parked — no WAV decode — so a
// host-driven cycle (a kLatencyChanged restart, an offline-render bracket) costs no
// disk read. With nothing to activate from, the full reload runs: it resolves +
// decodes from the instance-owned refs (no bank read required, so it plays regardless
// of PROJEXTSTATE parse state) and doubles as the non-editor legacy-lift trigger for a
// pre-v10 blob, whose opportunistic refreshRefsFromBank copies refs in when the bank
// blob is readable by now. Nothing can shadow that lift: a pre-v10 blob resolves
+22 -14
View File
@@ -139,9 +139,9 @@ public:
// What the audio thread published since the LAST call: peaks maxed and minGain minimised
// across every block in that window. CONSUMING — it resets the accumulators as it reads, so
// exactly one reader may call it, and that reader is the editor's meter tick. Two live
// editors would each consume half the windows and both meters would read low; what makes
// that unreachable is the HOST calling createView once per instance, not anything this
// plugin enforces — createView allocates a new editor on every call. UI thread.
// editors would each consume half the windows and both meters would read low; the single
// reader rests on the HOST calling createView once per instance `[verify — DAW]`, not on
// anything this plugin enforces — createView allocates a new editor on every call. UI thread.
MasterBusMeter masterBusMeter();
void clearMasterBusClip();
@@ -289,10 +289,12 @@ private:
void rebuildVoiceEngine();
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
// rebuilds the voice state around the parked sample and publishes it through the same
// drain-slot swap. True also when a publish landed while inactive, which needs no rebuild.
// False when there is nothing to activate from — the caller then falls back to a full
// reload, which is where the pre-v10 legacy lift lives. Off the audio thread only.
// folds the live bank blob into the refs, rebuilds the voice state around the parked
// sample, publishes it through the same drain-slot swap, and republishes usage. True also
// when a publish landed while inactive, which needs no rebuild. False when there is nothing
// to activate from, or when that fold moved what the park was decoded from — the caller
// then falls back to a full reload, which decodes the new file and is also where the pre-v10
// legacy lift lives. Off the audio thread only.
bool resumeDormantInstrument();
// Builds a playable snapshot around `sample` at the current voice-system parameters and
@@ -307,7 +309,8 @@ private:
// Publishes `built` (null = install silence) into live_: prunes the graveyard by the
// last process()-published generation, swaps `built` into live_, displaces the previous
// live into the drain slot, and parks the evicted drain instrument in the graveyard.
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
// Requires reloadMutex_ held — shared by reloadInstrument, rebuildVoiceEngine and
// resumeDormantInstrument.
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
@@ -391,6 +394,10 @@ private:
// reloadMutex_.
std::optional<SampleData> dormantSample_;
// Whether the host has us active, so setActive can treat a repeat of the state it already
// holds as a no-op (it owns why). Main/UI thread only, like setActive itself.
bool active_ = false;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread.
std::mutex selectionMutex_;
@@ -488,13 +495,14 @@ private:
instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false};
// Raised (and lowered) by the commit funnel from the difference between the enable and
// latencyAnnounced_, cleared by flushLatencyRestart on delivery. A bool and not a count on
// purpose: the host is being told to re-ASK, so N changes before one flush need exactly one
// restart, and whatever getLatencySamples answers at that moment is the truth announced.
// latencyAnnounced_, cleared by flushLatencyRestart on delivery and re-raised there if the
// host refuses. A bool and not a count on purpose: the host is being told to re-ASK, so N
// changes before one flush need exactly one restart, and whatever getLatencySamples answers
// at that moment is the truth announced.
std::atomic<bool> latencyRestartPending_{false};
// The enable the host was last told about — false initially, which is what an instance
// that has announced nothing reports. Every arm is judged against this, so a change that
// returns to the announced state costs no restart.
// The enable the host has ACCEPTED — false initially, which is what an instance that has
// announced nothing reports. Every arm is judged against this, so a change that returns to
// the announced state costs no restart.
std::atomic<bool> latencyAnnounced_{false};
// What the audio thread publishes about the output bus each block, relaxed. The peaks and