Ξ-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
+1
View File
@@ -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).
+275
View File
@@ -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
+25
View File
@@ -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