Ξ-W2-T1: the resample bake chain — instrument renders, extension banks, one click re-points and resets

This commit is contained in:
2026-08-01 16:26:28 -04:00
parent 6c982cd617
commit 60308a3655
52 changed files with 2212 additions and 55 deletions
+16 -3
View File
@@ -83,8 +83,12 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that captures, places a timeline
item, or writes back into the bank is a bug.
read-only act over the bank. Any instrument path that places a timeline item, or that
writes bank state itself, is a bug. **The resample bake is not an exception to that
and does not widen it:** the instrument RENDERS its own sound and REQUESTS a landing;
the extension is what captures the file into the bank and writes the index. The
instrument's whole outbound surface is one prefix-guarded request key plus one action
id — see `instrument_bake` and `reaper_bridge` below.
- The instrument never ingests. Capture, import, and drop-ingest are *extension*
acts; the instrument only reads and plays. A drop onto the editor window (if ever
shipped) is relayed to the extension as an ingest request — the instrument never
@@ -99,17 +103,26 @@ 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. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
- `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`.
- `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).
- `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.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules, stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
## Gotchas
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next.
- `editor_internal.h` is include-only — it has no TU of its own and must never become
a public seam; only the `reasampler_editor` band-axis TUs include it.
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
+3 -1
View File
@@ -66,6 +66,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
editor_input_curve.cpp
editor_stroke.cpp
editor_platform.cpp
instrument_bake.cpp
reasampler_embed.cpp
reaper_bridge.cpp
# draw_kit is compiled into each module rather than being a static library — see root
@@ -88,7 +89,8 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
file_bytes curve_law stroke_aa)
file_bytes curve_law stroke_aa
bake_plan bake_render bake_reset bake_wire wav_codec)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
@@ -40,6 +40,14 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
invalidate();
return true;
}
// Resample bake. Arm only: onSyncTimer runs it a tick later, off this handler's stack
// and with no mouse capture held (instrument_bake.h says why that matters). A click
// while the extension is absent is swallowed — the button already paints Disabled.
if (contains(cr.bake, x, y)) {
if (bakeAvailable_) bakePending_ = true;
invalidate();
return true;
}
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
// jumps the value; the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) {
@@ -109,6 +117,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
@@ -112,6 +112,9 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
} else {
title += " [host: no bridge]";
}
// A bake outcome outranks the identity readout while it lasts: the click's only other
// feedback is the sound itself, which is by design indistinguishable from before.
if (bakeMessageTicks_ > 0 && !bakeMessage_.empty()) title = bakeMessage_;
kitText(bmp, cr.title, title.c_str(), kToolbarFont, Role::TextPrimary);
// Browse: the picker. When nothing is loaded it is the empty state's dominant
@@ -128,6 +131,20 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
// nothing picked there is no root, no preview and no channel decision to make.
if (empty) return;
// Resample bake. Disabled without the extension: the bank is the extension's surface,
// so with it absent there is no writer and the control must read unavailable rather
// than accept a click it cannot honour.
{
const KitButtonBox box{toKitBox(cr.bake)};
const InteractionState st =
!bakeAvailable_ ? InteractionState::Disabled
: (bakePending_ ? InteractionState::Active
: (isHovered(HoverKind::kBake, -1)
? InteractionState::Hover
: InteractionState::Rest));
drawButton(bmp, box, "Bake", st, /*warn=*/false);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
// A drawn play triangle rather than a label or an embedded image: it inherits the button's
// own foreground role, so it stays legible in every interaction state at no build cost.
+27
View File
@@ -19,6 +19,7 @@
#include "ext_keys.h"
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
#include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
@@ -39,6 +40,10 @@ using ui::ThumbnailKey;
using ui::thumbnailKeyString;
using util::readFileBytes;
// Sync ticks a bake outcome stays in the title band. Long enough to read, short enough
// that it does not shadow the identity readout.
constexpr int kBakeMessageTicks = 20;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// The default IS the enforced floor (checkSizeConstraint) — the face opens at the size its
@@ -104,6 +109,28 @@ void ReaSamplerEditor::onSyncTimer() {
if (!processor_) return;
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
// can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge());
if (available != bakeAvailable_) {
bakeAvailable_ = available;
invalidate();
}
// The click armed it; this is where it runs — same thread, one tick later, no mouse
// capture held, and no REAPER action nested inside a mouse handler.
if (bakePending_) {
bakePending_ = false;
const BakeChainResult result = runBake(*processor_);
bakeMessage_ = result.message;
bakeMessageTicks_ = kBakeMessageTicks;
// A landed bake re-pointed the instance and reset the parameter set; re-snapshot
// so the face draws the new capture and its neutral controls.
if (result.ok) refreshFromBank();
invalidate();
}
if (bakeMessageTicks_ > 0 && --bakeMessageTicks_ == 0) invalidate();
// An open editor is the focused assignment target (thundering-herd policy); instances
// with no editor open never poll (the timer is bound to the child window).
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
+204
View File
@@ -0,0 +1,204 @@
// See instrument_bake.h.
#include "shell/instrument/instrument_bake.h"
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>
#include "core/capture/wav_codec.h" // buildFloat32Wav (the bank byte format)
#include "core/instrument/bake/bake_plan.h"
#include "core/instrument/bake/bake_render.h"
#include "core/instrument/bake/bake_reset.h"
#include "core/instrument/note/tempo.h"
#include "core/wire/bake_wire.h"
#include "ext_keys.h"
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using capture::buildFloat32Wav;
using instrument::bake::defaultBakeProgram;
using instrument::bake::planBake;
using instrument::bake::renderBake;
using instrument::bake::resetAfterBake;
using instrument::map::SampleRefEntry;
using instrument::map::SelectedSample;
using instrument::note::Tempo;
using instrument::note::resolveNote;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
namespace {
namespace fs = std::filesystem;
// Deletes the staged file on EVERY exit path, success or failure — the same stack-RAII
// discipline the capture shell's FX-bypass guard follows. On success the extension has
// already COPIED the bytes into the bank, so the delete here is what keeps the temp from
// outliving the click. Best-effort: a file already gone is not an error.
//
// Orphan policy: a crash between the write and the invoke leaves one file in the OS temp
// directory, which is exactly what that directory is swept for. Nothing here scans or
// deletes files it did not itself create.
class StagedFileGuard {
public:
explicit StagedFileGuard(std::string path) : path_(std::move(path)) {}
~StagedFileGuard() {
if (path_.empty()) return;
std::error_code ec;
fs::remove(path_, ec);
}
StagedFileGuard(const StagedFileGuard&) = delete;
StagedFileGuard& operator=(const StagedFileGuard&) = delete;
private:
std::string path_;
};
// Clears the request key on every exit path. A key left holding a request would be picked
// up by the next bake's landing pass and re-run against a temp file that no longer exists.
class RequestKeyGuard {
public:
RequestKeyGuard(ReaperBridge& bridge, std::string key)
: bridge_(bridge), key_(std::move(key)) {}
~RequestKeyGuard() { bridge_.writeBakeExtState(key_, ""); }
RequestKeyGuard(const RequestKeyGuard&) = delete;
RequestKeyGuard& operator=(const RequestKeyGuard&) = delete;
private:
ReaperBridge& bridge_;
std::string key_;
};
BakeChainResult fail(std::string message) {
return BakeChainResult{false, std::move(message)};
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
} // namespace
bool bakeAvailable(ReaperBridge& bridge) {
return bridge.isConnected() &&
bridge.extensionActionAvailable(wire::bakeActionLookupName());
}
BakeChainResult runBake(ReaSamplerProcessor& processor) {
ReaperBridge& bridge = processor.bridge();
if (!bakeAvailable(bridge))
return fail("resample needs the ReaSampler extension loaded");
const std::string selectionId = processor.selectedSampleId();
if (selectionId.empty()) return fail("nothing loaded to resample");
// The whole ref entry, not just its SelectedSample: the display name that the new
// capture's is derived from sits beside the intrinsics.
const instrument::map::SampleRefs refs = processor.sampleRefs();
const SampleRefEntry* sourceEntry = nullptr;
for (const SampleRefEntry& e : refs)
if (e.sampleId == selectionId) { sourceEntry = &e; break; }
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
const SelectedSample* source = &sourceEntry->ref;
const int sampleRate = static_cast<int>(processor.sampleRate());
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
if (!tempo) return fail("the project tempo could not be read");
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");
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);
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
// own 32-bit-float contract, so the round trip is exact.
std::vector<double> interleaved(audio.interleaved.begin(), audio.interleaved.end());
const std::vector<std::uint8_t> bytes =
buildFloat32Wav(audio.channelCount, static_cast<std::uint32_t>(audio.sampleRate),
static_cast<std::size_t>(audio.frameCount()), interleaved);
const std::string instanceGuid = processor.usageInstanceGuid();
const std::int64_t stamp = static_cast<std::int64_t>(std::time(nullptr));
// OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray
// file there would read as a prune orphan.
std::error_code ec;
const fs::path stagedPath =
fs::temp_directory_path(ec) /
("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav");
if (ec) return fail("no writable temp directory for the staged render");
const std::string staged = stagedPath.string();
StagedFileGuard stagedGuard(staged);
if (!writeFileBytes(staged, bytes)) return fail("could not stage the rendered file");
BakeRequest request;
request.instanceGuid = instanceGuid;
request.stagedFilePath = staged;
request.sourceSampleId = selectionId;
request.sourceRelativePath = source->relativePath;
request.sourceDisplayName = sourceEntry->displayName;
request.ownUsageKey = usageKeyFor(instanceGuid);
request.rootNote = plan->note;
request.generation = stamp;
const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key);
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("could not publish the bake request");
// Synchronous: the extension's landing runs to completion inside this call and writes
// its outcome back over the same key before returning.
if (!bridge.invokeExtensionAction(wire::bakeActionLookupName()))
return fail("the ReaSampler extension's bake action is not registered");
const std::optional<std::string> raw = bridge.readReasamplerExtState(key);
const std::optional<BakeOutcome> outcome =
raw ? wire::decodeBakeOutcome(*raw) : std::nullopt;
// No outcome at all means the action never reached our request — an older extension
// that registers the id but does not read this key, or an invocation REAPER deferred.
if (!outcome) return fail("the extension did not answer the bake request");
if (outcome->generation != request.generation)
return fail("the extension answered a different bake request");
if (outcome->status != BakeStatus::Ok)
return fail(outcome->message.empty() ? std::string("the bake was refused")
: outcome->message);
SampleRefEntry entry;
entry.sampleId = outcome->sampleId;
entry.displayName = outcome->displayName;
entry.ref.relativePath = outcome->relativePath;
entry.ref.rootNote = outcome->rootNote;
entry.ref.channelCount = outcome->channelCount;
// No loop: the loop points shaped the render and are meaningless against the new file
// (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer).
const instrument::bake::BakeReset reset = resetAfterBake(dialed);
processor.adoptBakedCapture(entry, reset.params, reset.masterGainLinear);
// The extension's own wording, which distinguishes the three landings (replaced, added,
// and pointed at an identical existing entry) more precisely than this side can.
return BakeChainResult{true, "resampled -- " + outcome->message};
}
} // namespace reasampler::vst
+36
View File
@@ -0,0 +1,36 @@
// instrument_bake — the instrument's half of the resample chain: render the dialed sound
// offline, stage it outside the bank, ask the extension to bank it, then re-point and go
// neutral. UI thread only; nothing here is on any audio path.
//
// The extension is REQUIRED. Without it the bank has no writer, so the affordance reads
// Disabled and this refuses rather than half-baking.
#pragma once
#include <string>
namespace reasampler::vst {
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.
bool bakeAvailable(ReaperBridge& bridge);
struct BakeChainResult {
bool ok = false;
std::string message; // always populated — the editor prints it either way
};
// Runs the whole chain against `processor`. On failure NOTHING has changed: no temp file
// survives, no bank entry was added, no assign was written, and the dialed state is
// exactly as it was before the click.
//
// MUST NOT be called from a mouse handler. It invokes a REAPER action synchronously — an
// offline landing nested inside a click, with the mouse captured, re-pointing the very
// instance whose frame is on the stack. The editor defers it to its own timer tick.
BakeChainResult runBake(ReaSamplerProcessor& processor);
} // namespace reasampler::vst
+70 -18
View File
@@ -77,6 +77,18 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
return out;
}
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
// never be built from a different fold than the one the user is hearing.
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
const InstrumentParams& params,
const std::string& projectDir, ChannelMode mode) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
if (!pcm) return std::nullopt;
SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
if (!sample.playable()) return std::nullopt;
return sample;
}
} // namespace
std::string ReaSamplerProcessor::reloadInstrument() {
@@ -144,25 +156,23 @@ std::string ReaSamplerProcessor::reloadInstrument() {
channelModeExplicit_);
mode = channelMode_;
}
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
havePlayable = sample.playable();
if (havePlayable) {
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
if (std::optional<SampleData> decoded =
buildFromRef(*sel, params, projectDir, mode)) {
sample = std::move(*decoded);
havePlayable = true;
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
}
@@ -190,6 +200,48 @@ std::string ReaSamplerProcessor::reloadInstrument() {
return resolvedId;
}
std::string ReaSamplerProcessor::usageInstanceGuid() {
std::lock_guard<std::mutex> lock(usageMutex_);
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
return instanceGuid_;
}
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt;
const InstrumentParams params = instrumentParams();
SampleRefs refs;
{
std::lock_guard<std::mutex> rl(refsMutex_);
refs = sampleRefs_;
}
const SelectedSample* sel = findRef(refs, selId);
if (!sel) return std::nullopt;
// No live block is attached: the bake renders the stored parameter set, which is what
// every knob has already written, rather than whatever the audio thread is observing.
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode());
}
void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
const InstrumentParams& reset,
double masterGainLinear) {
{
std::lock_guard<std::mutex> rl(refsMutex_);
bool replaced = false;
for (SampleRefEntry& existing : sampleRefs_) {
if (existing.sampleId == entry.sampleId) { existing = entry; replaced = true; break; }
}
if (!replaced) sampleRefs_.push_back(entry);
}
setSelectedSampleId(entry.sampleId);
setInstrumentParams(reset);
setMasterGainLinear(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.
reloadInstrument();
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
const std::vector<std::string>& ids) {
if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do
+71 -11
View File
@@ -42,6 +42,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
setProjExtState_ = nullptr;
getTrackGuid_ = nullptr;
guidToString_ = nullptr;
namedCommandLookup_ = nullptr;
mainOnCommandEx_ = nullptr;
getCursorPositionEx_ = nullptr;
timeMapGetTimeSigAtTime_ = nullptr;
hostApp_ = nullptr;
if (!context) return false;
@@ -69,6 +73,17 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
reaper->getReaperApi("GetTrackGUID"));
guidToString_ = reinterpret_cast<GuidToStringFn>(
reaper->getReaperApi("guidToString"));
// The bake crossing: resolve the extension's action id by name, then fire it against
// this instance's own project. All degrade to null gracefully — an unresolvable pair
// simply leaves the bake affordance disabled.
namedCommandLookup_ = reinterpret_cast<NamedCommandLookupFn>(
reaper->getReaperApi("NamedCommandLookup"));
mainOnCommandEx_ = reinterpret_cast<MainOnCommandExFn>(
reaper->getReaperApi("Main_OnCommandEx"));
getCursorPositionEx_ = reinterpret_cast<GetCursorPositionExFn>(
reaper->getReaperApi("GetCursorPositionEx"));
timeMapGetTimeSigAtTime_ = reinterpret_cast<TimeMapGetTimeSigAtTimeFn>(
reaper->getReaperApi("TimeMap_GetTimeSigAtTime"));
return getProjExtState_ != nullptr;
}
@@ -95,25 +110,70 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
return decodeGetProjExtState(read.apiReturn, read.value);
}
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
const std::string& value) {
bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix) {
if (!setProjExtState_ || !hostApp_) return false;
// Read-only-bank guard: this module writes usage keys and nothing else. A non-
// "rsusage_" key is refused rather than widening the instrument's write surface
// (banks/view/tail/assign stay extension-owned).
const std::string prefix = kProjExtUsageKeyPrefix;
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
// Read-only-BANK guard: this module writes the two sanctioned per-instance prefixes
// and nothing else. Any other key is refused rather than widening the instrument's
// write surface (banks/view/tail/assign stay extension-owned).
const std::string prefix = requiredPrefix;
if (key.compare(0, prefix.size(), prefix) != 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
// SetProjExtState returns the size of the extname's state — after storing a
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the
// publish path retries next reload tick; a silent drop would leave holds unprotected).
// A deliberate CLEAR (empty value) shrinks the state and can legitimately return 0,
// so it is reported as landed.
const int rv =
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
// Deliberately NO MarkProjectDirty: a usage change always rides a component-state
// change that already dirties the project.
return rv > 0;
setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return value.empty() ? true : rv > 0;
}
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
const std::string& value) {
return writeGuarded(usageKey, value, kProjExtUsageKeyPrefix);
}
bool ReaperBridge::writeBakeExtState(const std::string& bakeKey,
const std::string& value) {
return writeGuarded(bakeKey, value, kProjExtBakeKeyPrefix);
}
int ReaperBridge::lookupCommand(const std::string& commandName) {
if (!namedCommandLookup_ || commandName.empty()) return 0;
return namedCommandLookup_(commandName.c_str());
}
bool ReaperBridge::extensionActionAvailable(const std::string& commandName) {
return mainOnCommandEx_ != nullptr && lookupCommand(commandName) != 0;
}
bool ReaperBridge::invokeExtensionAction(const std::string& commandName) {
if (!mainOnCommandEx_ || !hostApp_) return false;
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.
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.
mainOnCommandEx_(command, 0, proj);
return true;
}
double ReaperBridge::projectTempoBpm() {
if (!timeMapGetTimeSigAtTime_ || !getCursorPositionEx_ || !hostApp_) return 0.0;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3);
int num = 0, denom = 0;
double tempo = 0.0;
timeMapGetTimeSigAtTime_(proj, getCursorPositionEx_(proj), &num, &denom, &tempo);
// The meter is read but not applied: the note model's beat is a quarter note by
// ruling (core/instrument/note/CLAUDE.md), so the undivided BPM is the right one.
return tempo;
}
std::string ReaperBridge::currentTrackGuid() {
+56 -8
View File
@@ -44,15 +44,45 @@ public:
// project or when unconnected. Not RT-safe.
std::string activeProjectDir();
// Writes THIS INSTANCE's usage record: the ONE sanctioned instrument-side ext-state
// write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's usageKeyFor); any
// other key is refused, enforcing the read-only-bank invariant structurally (banks/
// view/tail/assign stay unwritable from the instrument). Returns true iff written
// (the SetProjExtState return is checked). NOT RT-safe — publish sites are the
// off-audio-thread reload path only. Deliberately does NOT mark the project dirty: a
// usage change always rides a component-state change that already does.
// The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one
// key prefix and refusing every other key. That structural refusal is what keeps the
// read-only-BANK invariant intact — banks/view/tail/assign stay unwritable from here —
// and neither payload is bank state. Both return true iff the write landed (the
// SetProjExtState return is checked) and neither is RT-safe: the call sites are the
// off-audio-thread reload path and the editor's UI tick.
//
// Neither marks the project dirty. A usage change always rides a component-state change
// that already does; a bake request is transient and is cleared in the same tick.
// THIS INSTANCE's usage record. `usageKey` MUST carry the "rsusage_" prefix
// (ext_keys.h's usageKeyFor).
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
// THIS INSTANCE's resample-bake request. `bakeKey` MUST carry the "rsbake_" prefix
// (ext_keys.h's bakeKeyFor). An empty value clears the key.
bool writeBakeExtState(const std::string& bakeKey, const std::string& value);
// --- Extension action invocation (the bake crossing) ------------------------------
//
// `commandName` is the NamedCommandLookup spelling — the registered command_id string
// with a leading underscore, which the registration string itself does not carry.
// Whether the extension is loaded AND has registered this action. Used to paint the
// 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.
bool invokeExtensionAction(const std::string& commandName);
// The effective project tempo (BPM, quarter notes per minute) at the edit cursor of
// this instance's own project. 0.0 when unconnected or unresolvable — the caller
// refuses rather than substituting a tempo (no hardcoded rates or tempos in src/).
double projectTempoBpm();
// The canonical GUID string of the track hosting this FX instance (same rendering as
// the extension's track_guid::guidString, so usage records compare byte-equal
// against its live-FX enumeration). Empty when unconnected or no track context (the
@@ -70,9 +100,14 @@ private:
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current
// tab) follows the active project, same convention as the persist shell.
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
// Used ONLY by writeUsageExtState (prefix-guarded) — see the read-only-bank note there.
// Used ONLY by the two prefix-guarded writers — see the read-only-bank note there.
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
const char* value);
using NamedCommandLookupFn = int (*)(const char* commandName);
using MainOnCommandExFn = void (*)(int command, int flag, void* proj);
using GetCursorPositionExFn = double (*)(void* proj);
using TimeMapGetTimeSigAtTimeFn = void (*)(void* proj, double time, int* numOut,
int* denomOut, double* tempoOut);
// Opaque-pointer signatures so the header stays SDK-type-free; the GUID* is passed
// straight through, never dereferenced here.
using GetTrackGuidFn = void* (*)(void* tr);
@@ -85,6 +120,19 @@ private:
SetProjExtStateFn setProjExtState_ = nullptr;
GetTrackGuidFn getTrackGuid_ = nullptr;
GuidToStringFn guidToString_ = nullptr;
NamedCommandLookupFn namedCommandLookup_ = nullptr;
MainOnCommandExFn mainOnCommandEx_ = nullptr;
GetCursorPositionExFn getCursorPositionEx_ = nullptr;
TimeMapGetTimeSigAtTimeFn timeMapGetTimeSigAtTime_ = nullptr;
// The one prefix guard both public writers route through, so the two cannot diverge
// in how strictly they refuse a key.
bool writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix);
// 0 when the action is not registered (the extension is absent or older). REAPER's
// documented "not found" return is 0 by convention only — the header does not state
// it — so every caller treats 0 as unavailable and never as a valid command id.
int lookupCommand(const std::string& commandName);
};
} // namespace reasampler::vst
+11
View File
@@ -121,6 +121,7 @@ private:
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
@@ -422,6 +423,16 @@ private:
// up. One note at a time — a fresh press releases the prior.
int previewingNote_ = -1;
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
// would nest a synchronous REAPER action — which re-points this very instance — inside
// a mouse handler with the capture held.
bool bakePending_ = false;
// Whether the extension's bake action is registered, resolved on the same tick that
// governs the button's paint, so the control is never enabled and then refusing.
bool bakeAvailable_ = false;
std::string bakeMessage_; // last outcome, shown in the title band
int bakeMessageTicks_ = 0; // sync ticks the message survives
// The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
// an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
// down via the sync tick). Never ingests, never inserts a timeline item.
@@ -26,6 +26,7 @@ namespace reasampler::vst {
using instrument::map::ComponentState;
using instrument::map::InstrumentParams;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;
@@ -120,6 +121,21 @@ public:
// resolved selection id ("" if nothing loaded).
std::string reloadInstrument();
// The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
// build reloadInstrument runs, with no live block attached. Rebuilt rather than copied
// off the live snapshot because a tier-3 live edit leaves that snapshot's own play
// params stale on purpose — copying it would bake the pre-drag values. nullopt when
// nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
std::optional<SampleData> bakeSnapshot();
// 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.
void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset,
double masterGainLinear);
// What pollBankSync did this tick, so the editor can react only when something changed.
struct BankSyncResult {
bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded
@@ -203,6 +219,11 @@ public:
// fallback when the bank blob is unreadable. Guarded by refsMutex_.
SampleRefs sampleRefs();
// This instance's usage identity, minted here if it has never published. It names BOTH
// the "rsusage_" record the bake's tie query must exclude and the "rsbake_" request
// key, so the two can never name different instances. Off the audio thread.
std::string usageInstanceGuid();
private:
// If process() published that the drain instrument is fully idle, move it into the
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its