Ξ-W2-T1: the resample bake chain — instrument renders, extension banks, one click re-points and resets
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user