Ξ-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
+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