Files
reasampler/src/shell/instrument/instrument_bake.cpp
T
daniel 25390d5253 Prove every ext-state write by reading the key back, so no guard is constant-true
SetProjExtState's return describes the whole extname. The persist and the instrument's publish now re-read their key; both refusals can finally fire.
2026-08-02 13:14:49 -04:00

267 lines
12 KiB
C++

// 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::Velocity;
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;
// The snapshot comes first: the default program's window is derived from the sound it
// carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
// The note fires at the velocity the user has been auditioning at: three velocity curves
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
const Velocity velocity = Velocity::of(processor.previewVelocity());
const instrument::bake::PlannedBake planned = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity),
*tempo),
sampleRate, rootNote);
if (!planned.plan) {
return fail(planned.refusal == instrument::bake::BakeRefusal::PastFrameCeiling
? "the dialed sound is longer than one bake can hold"
: "the programmed capture window is empty");
}
const instrument::bake::BakePlan& plan = *planned.plan;
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), plan, processor.masterGainLinear());
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();
// Named for what it is used for here — the staged file's own name, nothing else.
// `request.generation` below takes its OWN, later timestamp: kMaxRequestAgeSeconds
// is a budget measured from the write, and re-using this one would silently spend it
// on the WAV write that happens in between.
const std::int64_t stageStamp = 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(stageStamp) + ".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;
// Stamped here, immediately before the publish below — AFTER the WAV write above,
// which is what makes kMaxRequestAgeSeconds' budget (bake_wire.h) exclude staging
// time rather than eat into it.
request.generation = static_cast<std::int64_t>(std::time(nullptr));
const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key);
// The bridge PROVES this by reading the key back, so a false here is real: the
// extension would find nothing to land. It does not say which of the three ways failed,
// so neither does this sentence.
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("the bake request under " + key +
" could not be confirmed -- it was either never written or did not "
"read back as written, so the extension has nothing to land");
// 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");
// What the key holds now is the only evidence this side gets, and each of the five
// non-answers is a different thing to go fix — collapsing them into one sentence is
// what made a stale install indistinguishable from a refusal. All five name the KEY,
// because the landing prints one console line per key it scanned and the key is what
// correlates the two in a multi-instance session.
const wire::BakeAnswer answer =
wire::classifyBakeAnswer(bridge.readReasamplerExtState(key), request);
switch (answer.kind) {
case wire::BakeAnswerKind::Answered:
break;
case wire::BakeAnswerKind::Unanswered:
return fail("the ReaSampler extension did not answer " + key +
" -- that key still holds this exact request, untouched. The "
"landing action prints one REAPER console line per key it "
"scanned; look for that key name there");
case wire::BakeAnswerKind::Undecodable:
return fail("the value under " + key +
" is neither a request nor an answer this build can read -- the "
"extension and ReaSampler 9000 may be from different builds");
case wire::BakeAnswerKind::Cleared:
return fail("the bake key " + key +
" came back empty -- either the request was cleared before an "
"answer was written, or this build could not read whatever was "
"there. Look for that key name in the landing action's console "
"lines");
case wire::BakeAnswerKind::ForeignRequest:
return fail("the bake key " + key +
" held a different pending request instead of an answer -- "
"unexpected given the bake's single-threaded call flow; if this "
"recurs, note the exact steps and file it");
case wire::BakeAnswerKind::ForeignOutcome:
return fail("the answer under " + key +
" is for a different bake request than this one");
}
// wire::answeredOutcome is the guard, not switch exhaustiveness alone: the switch
// above has no `default`, so a future BakeAnswerKind enumerator it doesn't yet handle
// would otherwise fall through to a raw `*answer.outcome` deref with nothing set.
const BakeOutcome* outcomePtr = wire::answeredOutcome(answer);
if (!outcomePtr)
return fail(
"the extension's answer was not one this build recognizes -- the extension "
"and ReaSampler 9000 may be from different builds");
const BakeOutcome& outcome = *outcomePtr;
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