Ξ-W2-T1: the resample bake chain — instrument renders, extension banks, one click re-points and resets
This commit is contained in:
@@ -37,6 +37,7 @@ detail not covered there:
|
||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
||||
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
|
||||
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
|
||||
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: lands every pending `rsbake_*` request in the active project and answers over the same key, inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert".
|
||||
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
|
||||
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
|
||||
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
|
||||
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
|
||||
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
|
||||
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
|
||||
|
||||
#include "shell/capture/bake_land.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/model/resample_name.h" // the iteration-chain display name
|
||||
#include "core/tracking/tracking_authority.h" // resampleLanding (replace vs add-distinct)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
#include "core/wire/bake_wire.h"
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
|
||||
#include "ext_keys.h"
|
||||
#include "shell/panel/panel_input.h" // bankPanelRefresh
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_EnumProjExtState
|
||||
#define REAPERAPI_WANT_GetProjExtState
|
||||
#define REAPERAPI_WANT_SetProjExtState
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
using model::AddResult;
|
||||
using model::Sample;
|
||||
using model::nextIterationName;
|
||||
using wire::BakeOutcome;
|
||||
using wire::BakeRequest;
|
||||
using wire::BakeStatus;
|
||||
|
||||
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
|
||||
BakeOutcome out;
|
||||
out.status = status;
|
||||
out.message = std::move(message);
|
||||
out.generation = generation;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string currentProjectDir() {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
|
||||
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
|
||||
std::string& bankIdOut) {
|
||||
for (const Bank& b : book.banks()) {
|
||||
if (const Sample* s = b.index.query(sampleId)) {
|
||||
bankIdOut = b.id;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Lands ONE request. Mutates the book + ledger without persisting; the caller persists
|
||||
// once for the batch. Every refusal path leaves the book untouched and writes no file, so
|
||||
// a failed bake is invisible to the project.
|
||||
BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty())
|
||||
return refuse(BakeStatus::NoProject,
|
||||
"no saved project, so the bank has no location", request.generation);
|
||||
|
||||
const std::vector<std::uint8_t> bytes = util::readFileBytes(request.stagedFilePath);
|
||||
if (bytes.empty())
|
||||
return refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
|
||||
request.generation);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.frameCount() == 0)
|
||||
return refuse(BakeStatus::StagedMissing, "the staged render is not a usable WAV",
|
||||
request.generation);
|
||||
|
||||
BankBook& book = session.book();
|
||||
std::string bankId;
|
||||
const Sample* source = findSourceSample(book, request.sourceSampleId, bankId);
|
||||
if (!source)
|
||||
return refuse(BakeStatus::NoSource, "the resampled capture is not in any bank",
|
||||
request.generation);
|
||||
// Copied, not aliased: every mutation below invalidates the book's pointers.
|
||||
const Sample sourceCopy = *source;
|
||||
|
||||
const tracking::Landing landing = tracking::resampleLanding(
|
||||
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
|
||||
const bool replace = (landing == tracking::Landing::Replace);
|
||||
|
||||
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
|
||||
// byte-identical bake yields no second entry. Replace never dedups, matching
|
||||
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
|
||||
const std::string contentHash = hashWavContent(bytes);
|
||||
|
||||
BakeOutcome out;
|
||||
out.generation = request.generation;
|
||||
out.rootNote = request.rootNote;
|
||||
out.channelCount = static_cast<int>(layout.channelCount);
|
||||
out.replaced = replace;
|
||||
|
||||
if (!replace && !contentHash.empty()) {
|
||||
if (const model::BankModel* index = book.index(bankId)) {
|
||||
if (const Sample* existing = index->findByHash(contentHash)) {
|
||||
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
|
||||
// so it re-points at that entry rather than depositing an unreferenced
|
||||
// twin for the prune to reclaim later.
|
||||
out.status = BakeStatus::Ok;
|
||||
out.sampleId = existing->id;
|
||||
out.relativePath = existing->relativePath;
|
||||
out.displayName = existing->displayName;
|
||||
out.message = "identical to an existing capture -- pointed at it";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
||||
// Seconds alone are not unique enough here: two bakes of the same source inside one
|
||||
// second would derive the same file name, and the second would overwrite a file the
|
||||
// first had just indexed. The content hash separates them, and leaves a re-bake of
|
||||
// identical bytes idempotent rather than duplicated.
|
||||
const std::string uniqueTag =
|
||||
std::to_string(nowSec) +
|
||||
(contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
|
||||
const std::string stem =
|
||||
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
|
||||
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
|
||||
|
||||
std::error_code ec;
|
||||
fs::create_directories(paths.absoluteDir, ec); // idempotent; the write reports failure
|
||||
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
if (!writeFileBytes(destPath, bytes))
|
||||
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
|
||||
request.generation);
|
||||
|
||||
Sample landed;
|
||||
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
|
||||
// become a different capture. The superseded FILE is untouched: it stays on disk,
|
||||
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
|
||||
landed.id = replace ? sourceCopy.id
|
||||
: ("bake-" + uniqueTag + "-" + paths.fileName);
|
||||
landed.displayName =
|
||||
replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
|
||||
landed.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
landed.channelCount = static_cast<int>(layout.channelCount);
|
||||
landed.sampleRate = static_cast<int>(layout.sampleRate);
|
||||
landed.lengthSeconds =
|
||||
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
|
||||
static_cast<double>(layout.sampleRate)
|
||||
: 0.0;
|
||||
landed.rootNote = request.rootNote; // rendered AT root — that is what makes it survive
|
||||
landed.tier = model::Tier::Scratch;
|
||||
landed.contentHash = contentHash;
|
||||
landed.createdTimestamp = nowSec;
|
||||
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
|
||||
// repeated bake readable as one iteration chain.
|
||||
landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
|
||||
|
||||
const bool indexed = replace
|
||||
? book.updateSampleInPlace(sourceCopy.id, landed)
|
||||
: (book.index(bankId) &&
|
||||
book.index(bankId)->add(landed) == AddResult::Added);
|
||||
if (!indexed) {
|
||||
// Self-cleanup of a file this call wrote seconds ago and never indexed — not the
|
||||
// prune's deletion authority, which governs files the bank knows about. Leaving it
|
||||
// would deposit an untracked orphan for every refused bake.
|
||||
fs::remove(destPath, ec);
|
||||
return refuse(BakeStatus::IndexRejected,
|
||||
replace ? "the bank refused the replacement"
|
||||
: "the bank refused the new capture",
|
||||
request.generation);
|
||||
}
|
||||
session.recordCreated(landed, tracking::OriginKind::Capture);
|
||||
|
||||
out.status = BakeStatus::Ok;
|
||||
out.sampleId = landed.id;
|
||||
out.relativePath = landed.relativePath;
|
||||
out.displayName = landed.displayName;
|
||||
out.message = replace ? "replaced the bank entry" : "added as a distinct capture";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every "rsbake_*" key in the project, keys only — the value can outgrow
|
||||
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
|
||||
std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
|
||||
std::vector<std::string> keys;
|
||||
const std::string prefix = kProjExtBakeKeyPrefix;
|
||||
char keyBuf[256];
|
||||
for (int idx = 0;; ++idx) {
|
||||
keyBuf[0] = '\0';
|
||||
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
|
||||
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
|
||||
break;
|
||||
const std::string key(keyBuf);
|
||||
if (key.compare(0, prefix.size(), prefix) == 0) keys.push_back(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
std::optional<std::string> readKey(ReaProject* proj, const std::string& key) {
|
||||
const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
|
||||
return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
|
||||
});
|
||||
if (read.status != wire::GrowingExtStateRead::Status::Complete) return std::nullopt;
|
||||
return read.value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RunResampleBake(ReaSamplerSession& session) {
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) return;
|
||||
|
||||
int landedCount = 0;
|
||||
Undo_BeginBlock2(nullptr);
|
||||
for (const std::string& key : pendingBakeKeys(proj)) {
|
||||
const std::optional<std::string> raw = readKey(proj, key);
|
||||
if (!raw) continue;
|
||||
const std::optional<BakeRequest> request = wire::decodeBakeRequest(*raw);
|
||||
// Not a request: an outcome this instance has not yet collected, or a value from a
|
||||
// build we do not read. Leave it — the writing instance owns clearing its own key.
|
||||
if (!request) continue;
|
||||
|
||||
const BakeOutcome outcome = landOne(session, *request);
|
||||
if (outcome.status == BakeStatus::Ok) ++landedCount;
|
||||
SetProjExtState(proj, kProjExtNamespace(), key.c_str(),
|
||||
wire::encodeBakeOutcome(outcome).c_str());
|
||||
if (outcome.status != BakeStatus::Ok)
|
||||
ShowConsoleMsg(("ReaSampler resample: " + outcome.message + ".\n").c_str());
|
||||
}
|
||||
|
||||
if (landedCount > 0) {
|
||||
// A bake changes what a live instance would play, so the generation bump rides the
|
||||
// persist — every other open instance refreshes hands-free.
|
||||
session.bumpBankGeneration();
|
||||
const bool persisted = session.saveToActiveProject();
|
||||
Undo_EndBlock2(nullptr,
|
||||
persisted ? "ReaSampler: resample bake into bank" : "",
|
||||
persisted ? UNDO_STATE_MISCCFG : 0);
|
||||
bankPanelRefresh();
|
||||
} else {
|
||||
Undo_EndBlock2(nullptr, "", 0); // nothing landed — record no empty undo point
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
// bake_land — the EXTENSION's half of the resample chain: take the file a ReaSampler 9000
|
||||
// instance staged outside the bank, land it as a bank capture, and answer over the same
|
||||
// per-instance key the request arrived on.
|
||||
//
|
||||
// Renders nothing (the instrument already did, through its own engine, in its own process
|
||||
// — which is what makes the bake the sound the user approved). Writes a file into the bank
|
||||
// folder and an index entry, and NOTHING else: no timeline item, and no deletion — the
|
||||
// superseded audio survives until a prune reclaims it.
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
}
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The bake action's body: lands every pending "rsbake_*" request in the active project,
|
||||
// one undo point for the batch. Normally there is exactly one — the instance that just
|
||||
// invoked us. Runs synchronously inside the invoking instance's Main_OnCommandEx call, so
|
||||
// the answer is available to it the moment this returns.
|
||||
void RunResampleBake(ReaSamplerSession& session);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
|
||||
|
||||
## Modules
|
||||
|
||||
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
|
||||
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
|
||||
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
|
||||
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
|
||||
|
||||
|
||||
@@ -204,6 +204,19 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
|
||||
} // namespace
|
||||
|
||||
tracking::Answer ReaSamplerSession::tiedUsageFor(const std::string& capturePath,
|
||||
const std::string& ownUsageKey) const {
|
||||
// The SECOND consumer of the same gather the prune scan above runs — deliberately
|
||||
// next to it, so "both answers come out of one TrackingState" is structural rather
|
||||
// than a rule two files have to remember.
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return tracking::Answer::Indeterminate;
|
||||
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
|
||||
const tracking::TrackingState state{trackingStatus_, tracking_, usage};
|
||||
return tracking::tiedUsageExists(state, capturePath, ownUsageKey);
|
||||
}
|
||||
|
||||
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
|
||||
reclaim::PruneReport report =
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/reclaim/prune_reconcile.h"
|
||||
#include "core/tracking/origin_ledger.h"
|
||||
#include "core/tracking/tracking_authority.h"
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/view/view_mode_model.h"
|
||||
|
||||
@@ -115,6 +116,13 @@ public:
|
||||
// prune action confirms this set before deleting it. Read-only.
|
||||
std::vector<std::string> pruneOrphanSet() const;
|
||||
|
||||
// The resample's replace-vs-add input for one capture, gathered from the SAME live
|
||||
// tracking state the prune scan reads, so the two consumers cannot disagree. Exposed
|
||||
// as the answer rather than as the ledger, because an absent record and an unreadable
|
||||
// ledger demand opposite treatment and only the pair says which. Read-only.
|
||||
tracking::Answer tiedUsageFor(const std::string& capturePath,
|
||||
const std::string& ownUsageKey) const;
|
||||
|
||||
// Delete the confirmed orphan set — the sole file-deletion path,
|
||||
// callable only after an explicit user confirm. Re-enumerates and runs
|
||||
// the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a
|
||||
|
||||
Reference in New Issue
Block a user