Ξ-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
+204
View File
@@ -0,0 +1,204 @@
// See instrument_bake.h.
#include "shell/instrument/instrument_bake.h"
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>
#include "core/capture/wav_codec.h" // buildFloat32Wav (the bank byte format)
#include "core/instrument/bake/bake_plan.h"
#include "core/instrument/bake/bake_render.h"
#include "core/instrument/bake/bake_reset.h"
#include "core/instrument/note/tempo.h"
#include "core/wire/bake_wire.h"
#include "ext_keys.h"
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using capture::buildFloat32Wav;
using instrument::bake::defaultBakeProgram;
using instrument::bake::planBake;
using instrument::bake::renderBake;
using instrument::bake::resetAfterBake;
using instrument::map::SampleRefEntry;
using instrument::map::SelectedSample;
using instrument::note::Tempo;
using instrument::note::resolveNote;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
namespace {
namespace fs = std::filesystem;
// Deletes the staged file on EVERY exit path, success or failure — the same stack-RAII
// discipline the capture shell's FX-bypass guard follows. On success the extension has
// already COPIED the bytes into the bank, so the delete here is what keeps the temp from
// outliving the click. Best-effort: a file already gone is not an error.
//
// Orphan policy: a crash between the write and the invoke leaves one file in the OS temp
// directory, which is exactly what that directory is swept for. Nothing here scans or
// deletes files it did not itself create.
class StagedFileGuard {
public:
explicit StagedFileGuard(std::string path) : path_(std::move(path)) {}
~StagedFileGuard() {
if (path_.empty()) return;
std::error_code ec;
fs::remove(path_, ec);
}
StagedFileGuard(const StagedFileGuard&) = delete;
StagedFileGuard& operator=(const StagedFileGuard&) = delete;
private:
std::string path_;
};
// Clears the request key on every exit path. A key left holding a request would be picked
// up by the next bake's landing pass and re-run against a temp file that no longer exists.
class RequestKeyGuard {
public:
RequestKeyGuard(ReaperBridge& bridge, std::string key)
: bridge_(bridge), key_(std::move(key)) {}
~RequestKeyGuard() { bridge_.writeBakeExtState(key_, ""); }
RequestKeyGuard(const RequestKeyGuard&) = delete;
RequestKeyGuard& operator=(const RequestKeyGuard&) = delete;
private:
ReaperBridge& bridge_;
std::string key_;
};
BakeChainResult fail(std::string message) {
return BakeChainResult{false, std::move(message)};
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
} // namespace
bool bakeAvailable(ReaperBridge& bridge) {
return bridge.isConnected() &&
bridge.extensionActionAvailable(wire::bakeActionLookupName());
}
BakeChainResult runBake(ReaSamplerProcessor& processor) {
ReaperBridge& bridge = processor.bridge();
if (!bakeAvailable(bridge))
return fail("resample needs the ReaSampler extension loaded");
const std::string selectionId = processor.selectedSampleId();
if (selectionId.empty()) return fail("nothing loaded to resample");
// The whole ref entry, not just its SelectedSample: the display name that the new
// capture's is derived from sits beside the intrinsics.
const instrument::map::SampleRefs refs = processor.sampleRefs();
const SampleRefEntry* sourceEntry = nullptr;
for (const SampleRefEntry& e : refs)
if (e.sampleId == selectionId) { sourceEntry = &e; break; }
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
const SelectedSample* source = &sourceEntry->ref;
const int sampleRate = static_cast<int>(processor.sampleRate());
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
if (!tempo) return fail("the project tempo could not be read");
const InstrumentParams dialed = processor.instrumentParams();
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
const auto plan = planBake(resolveNote(defaultBakeProgram(), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
const instrument::bake::BakeAudio audio = renderBake(std::move(*snapshot), *plan);
if (audio.empty()) return fail("the offline pass produced no audio");
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
// own 32-bit-float contract, so the round trip is exact.
std::vector<double> interleaved(audio.interleaved.begin(), audio.interleaved.end());
const std::vector<std::uint8_t> bytes =
buildFloat32Wav(audio.channelCount, static_cast<std::uint32_t>(audio.sampleRate),
static_cast<std::size_t>(audio.frameCount()), interleaved);
const std::string instanceGuid = processor.usageInstanceGuid();
const std::int64_t stamp = static_cast<std::int64_t>(std::time(nullptr));
// OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray
// file there would read as a prune orphan.
std::error_code ec;
const fs::path stagedPath =
fs::temp_directory_path(ec) /
("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav");
if (ec) return fail("no writable temp directory for the staged render");
const std::string staged = stagedPath.string();
StagedFileGuard stagedGuard(staged);
if (!writeFileBytes(staged, bytes)) return fail("could not stage the rendered file");
BakeRequest request;
request.instanceGuid = instanceGuid;
request.stagedFilePath = staged;
request.sourceSampleId = selectionId;
request.sourceRelativePath = source->relativePath;
request.sourceDisplayName = sourceEntry->displayName;
request.ownUsageKey = usageKeyFor(instanceGuid);
request.rootNote = plan->note;
request.generation = stamp;
const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key);
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("could not publish the bake request");
// Synchronous: the extension's landing runs to completion inside this call and writes
// its outcome back over the same key before returning.
if (!bridge.invokeExtensionAction(wire::bakeActionLookupName()))
return fail("the ReaSampler extension's bake action is not registered");
const std::optional<std::string> raw = bridge.readReasamplerExtState(key);
const std::optional<BakeOutcome> outcome =
raw ? wire::decodeBakeOutcome(*raw) : std::nullopt;
// No outcome at all means the action never reached our request — an older extension
// that registers the id but does not read this key, or an invocation REAPER deferred.
if (!outcome) return fail("the extension did not answer the bake request");
if (outcome->generation != request.generation)
return fail("the extension answered a different bake request");
if (outcome->status != BakeStatus::Ok)
return fail(outcome->message.empty() ? std::string("the bake was refused")
: outcome->message);
SampleRefEntry entry;
entry.sampleId = outcome->sampleId;
entry.displayName = outcome->displayName;
entry.ref.relativePath = outcome->relativePath;
entry.ref.rootNote = outcome->rootNote;
entry.ref.channelCount = outcome->channelCount;
// No loop: the loop points shaped the render and are meaningless against the new file
// (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer).
const instrument::bake::BakeReset reset = resetAfterBake(dialed);
processor.adoptBakedCapture(entry, reset.params, reset.masterGainLinear);
// The extension's own wording, which distinguishes the three landings (replaced, added,
// and pointed at an identical existing entry) more precisely than this side can.
return BakeChainResult{true, "resampled -- " + outcome->message};
}
} // namespace reasampler::vst