Ξ-W2-T1 remediation: print master gain into the bake, derive the window from the dialed sound, reset play mode to Trigger

This commit is contained in:
2026-08-01 17:05:28 -04:00
parent 60308a3655
commit 39c2d1cdb4
31 changed files with 679 additions and 201 deletions
+1 -1
View File
@@ -103,7 +103,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. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` against `getReaperParent(3)`, the instance's OWN project tab, never the focused one) and `projectTempoBpm`.
- `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. 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 audible cut to ringing tails. **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.
- `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).
+9 -4
View File
@@ -121,14 +121,19 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const InstrumentParams dialed = processor.instrumentParams();
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
const auto plan = planBake(resolveNote(defaultBakeProgram(), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
// The snapshot comes first: the default program's window is derived from the sound it
// carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
const instrument::bake::BakeAudio audio = renderBake(std::move(*snapshot), *plan);
const auto plan = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), *plan, processor.masterGainLinear());
if (audio.empty()) return fail("the offline pass produced no audio");
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
+3 -2
View File
@@ -15,8 +15,9 @@ class ReaSamplerProcessor;
class ReaperBridge;
// Whether a bake can run at all right now: a REAPER host, and the extension's bake action
// registered. Cheap enough for the editor's sync tick, which is where the button's paint
// state is decided — the control must never be enabled and then refuse.
// registered. Runs one NamedCommandLookup and builds one std::string per call — cost
// unmeasured. It is on the editor's sync tick because that is where the button's paint
// state is decided, and the control must never be enabled and then refuse.
bool bakeAvailable(ReaperBridge& bridge);
struct BakeChainResult {
+16 -3
View File
@@ -235,10 +235,15 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
}
setSelectedSampleId(entry.sampleId);
setInstrumentParams(reset);
setMasterGainLinear(masterGainLinear);
{
// ARMED, not stored: publishBuiltLocked applies it inside the same locked section as
// the pointer swap. Storing it here instead would put the reset gain under the OLD
// capture for the whole bridge-read-plus-WAV-decode the reload below runs first.
std::lock_guard<std::mutex> lock(reloadMutex_);
gainAtNextPublish_ = masterGainLinear;
}
// ONE reload for the re-point and the reset together: it decodes the new file and
// publishes the neutral parameters in the same swap, so no block is ever rendered with
// one of the two applied and not the other.
// publishes the neutral parameters in the same swap.
reloadInstrument();
}
@@ -292,6 +297,14 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
}),
graveyard_.end());
LoadedInstrument* prev = live_.exchange(built.release());
// A bake's reset gain lands here rather than at its call site, so the gain and the
// capture it belongs to become audible to process() in the same instant. A tail still
// ringing out of the drain does take the new gain — one gain sits above every snapshot,
// the same shape as ONE BLOCK, ONE RATE (see builtSampleRate_).
if (gainAtNextPublish_) {
setMasterGainLinear(*gainAtNextPublish_);
gainAtNextPublish_.reset();
}
LoadedInstrument* evicted = draining_.exchange(prev);
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
}
+3 -2
View File
@@ -155,8 +155,9 @@ bool ReaperBridge::invokeExtensionAction(const std::string& commandName) {
const int command = lookupCommand(commandName);
if (command == 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
// getReaperParent(3), not the focused tab: an instance in a background tab must bake
// into ITS OWN project's bank.
// getReaperParent(3), not the focused tab — the ask is that an instance in a background
// tab bakes into ITS OWN project's bank (see the header on what that does and does not
// guarantee).
void* proj = reaper->getReaperParent(3);
// flag 0 is the conventional "no modifier" value; the header documents no other, and
// the extension's hookcommand ignores it.
+9 -5
View File
@@ -71,11 +71,15 @@ public:
// affordance: an unavailable action must read Disabled, never enabled-then-refusing.
bool extensionActionAvailable(const std::string& commandName);
// Fires the action against THIS INSTANCE's own project tab (getReaperParent(3)), not
// whichever tab is focused. Returns false when the action is unregistered — the
// invocation itself reports nothing, so a caller learns the result from the state the
// action wrote, never from here. Must NOT be called from a mouse handler: it runs the
// extension's whole bake landing synchronously, and the action re-points this instance.
// Fires the action with THIS INSTANCE's own project tab (getReaperParent(3)) as
// Main_OnCommandEx's `proj`, rather than leaving it to whichever tab is focused. What
// REAPER then makes current for the action's duration is NOT verified in the DAW, so
// this is a request, not a guarantee — the extension side re-derives which project a
// request came from and refuses one it cannot safely land. Returns false when the action
// is unregistered; the invocation itself reports nothing, so a caller learns the result
// from the state the action wrote, never from here. Must NOT be called from a mouse
// handler: it runs the extension's whole bake landing synchronously, and the action
// re-points this instance.
bool invokeExtensionAction(const std::string& commandName);
// The effective project tempo (BPM, quarter notes per minute) at the edit cursor of
+8 -3
View File
@@ -11,6 +11,7 @@
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
@@ -130,9 +131,9 @@ public:
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
// selection, the parameter set and master gain go neutral, and a single reload
// publishes both together. The ordering is the whole point — a reset published ahead
// of the re-point would put neutral parameters under the OLD capture for a block.
// UI thread only.
// publishes all three together. The ordering is the whole point — anything published
// ahead of the re-point would apply neutral settings to the OLD capture, which is the
// one thing they are meaningless against. UI thread only.
void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset,
double masterGainLinear);
@@ -304,6 +305,10 @@ private:
std::atomic<std::uint64_t> drainIdleGeneration_{0};
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// A master gain that must reach the audio thread in the SAME swap as the next publish —
// the bake's reset, which the old capture would be the wrong thing to apply it to.
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread.