Make a bake's Banked landing reachable only through the persist upgrade

The dedup path claimed it directly, so a key could be answered Ok pointing at an entry a failed persist never wrote. Write-back is now guarded and three-valued, and landing one request split out of the scan pass.
This commit is contained in:
2026-08-02 12:35:06 -04:00
parent 1800bd64c3
commit 0afb885987
10 changed files with 664 additions and 371 deletions
+2 -1
View File
@@ -57,7 +57,8 @@ detail not covered there:
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
- `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: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was rejected, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::bakeWriteLanded`), because `SetProjExtState`'s return describes the whole extname's state and cannot speak for one key; an answer that did not land is the one no-answer the tally alone cannot show. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing the project would not take is answered as a failure rather than as an `Ok` no reload would honour. The undo block is stack RAII (`UndoBlock`), and the landing is split into a non-mutating `prepareLanding` and a mutating `commitLanding` under separate catches — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial, and neither an exception nor an early return can leave an undo block open or discard the buffered answers. 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". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header.
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::bakeWriteLanded`), because `SetProjExtState`'s return describes the whole extname's state and cannot speak for one key; an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that itself failed or overflowed reports Unknown, never a rejection, because "we could not check" is a different claim from "it did not land". Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing the project would not take is answered as a failure rather than as an `Ok` no reload would honour`wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. 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.
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. 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" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
- `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).
+96 -313
View File
@@ -1,31 +1,23 @@
// 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.
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is bake_landing's.
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#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/capture/capture_paths.h" // projectDirOfRpp
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#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/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h"
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
@@ -44,256 +36,10 @@ 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;
}
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;
}
// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
std::optional<BakeOutcome> settled; // a refusal or a dedup hit: nothing left to commit
BakeOutcome outcome; // the shape a successful commit completes
Sample landed; // the entry the commit indexes
std::vector<std::uint8_t> bytes; // the staged file, already read
std::string bankId;
std::string destDir;
std::string destPath;
bool replace = false;
};
PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
PreparedLanding prep;
if (projectDir.empty()) {
prep.settled = refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location",
request.generation);
return prep;
}
prep.bytes = util::readFileBytes(request.stagedFilePath);
if (prep.bytes.empty()) {
prep.settled = refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuse(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
return prep;
}
BankBook& book = session.book();
const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
if (!source) {
prep.settled = refuse(BakeStatus::NoSource,
"the resampled capture is not in any bank", request.generation);
return prep;
}
// Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
prep.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(prep.bytes);
prep.outcome.generation = request.generation;
prep.outcome.rootNote = request.rootNote;
prep.outcome.channelCount = static_cast<int>(layout.channelCount);
prep.outcome.replaced = prep.replace;
if (!prep.replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(prep.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.
BakeOutcome out = prep.outcome;
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";
prep.settled = std::move(out);
return prep;
}
}
}
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);
prep.destDir = paths.absoluteDir;
prep.destPath = paths.absoluteDir + "/" + paths.fileName;
// 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.
prep.landed.id =
prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
prep.landed.displayName =
prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
prep.landed.relativePath = paths.relativePath; // project-relative (invariant)
prep.landed.channelCount = static_cast<int>(layout.channelCount);
prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
prep.landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
// Rendered AT root — that is what makes it survive.
prep.landed.rootNote = request.rootNote;
prep.landed.tier = model::Tier::Scratch;
prep.landed.contentHash = contentHash;
prep.landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
return prep;
}
// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort. Mutates book + ledger without persisting; the caller persists once for
// the batch.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
std::error_code ec;
fs::create_directories(prep.destDir, ec); // idempotent; the write reports failure
if (!writeFileBytes(prep.destPath, prep.bytes)) {
// A failed write can still have created the file before failing; withdraw it for
// the same reason the refused index below does.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
prep.outcome.generation);
}
BankBook& book = session.book();
// On the replace path prepareLanding set `landed.id` to the source's own id, so this is
// the entry being refreshed in place.
const bool indexed =
prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
: (book.index(prep.bankId) &&
book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::IndexRejected,
prep.replace ? "the bank refused the replacement"
: "the bank refused the new capture",
prep.outcome.generation);
}
session.recordCreated(prep.landed, tracking::OriginKind::Capture);
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = prep.landed.id;
out.relativePath = prep.landed.relativePath;
out.displayName = prep.landed.displayName;
out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// One request landed, with the two failure truths kept apart: a throw out of the prepare
// half is a refusal that left the project untouched, while a throw out of the commit half
// may have written a file and changed the book — calling that one "refused" would tell the
// user nothing happened when something did.
struct LandingAttempt {
BakeOutcome outcome;
bool partial = false; // the commit threw: a file and/or an entry may exist
bool changedBook = false; // the book gained or refreshed an entry, unpersisted so far
};
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
LandingAttempt attempt;
PreparedLanding prep;
try {
prep = prepareLanding(session, projectDir, request);
} catch (const std::exception& e) {
attempt.outcome =
refuse(BakeStatus::Failed,
std::string("the landing failed before it wrote anything: ") + e.what(),
request.generation);
return attempt;
} catch (...) {
attempt.outcome =
refuse(BakeStatus::Failed,
"the landing failed before it wrote anything, for an unknown reason",
request.generation);
return attempt;
}
if (prep.settled) {
attempt.outcome = std::move(*prep.settled);
return attempt; // a refusal, or a dedup hit that changed nothing
}
try {
attempt.outcome = commitLanding(session, prep);
attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
} catch (const std::exception& e) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
std::string("the landing failed while writing: ") + e.what(),
request.generation);
} catch (...) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
"the landing failed while writing, for an unknown reason",
request.generation);
}
return attempt;
}
// Every "rsbake_*" key in one 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) {
@@ -329,15 +75,6 @@ KeyRead readKey(ReaProject* proj, const std::string& key) {
return out;
}
// Writes one value under `key` and PROVES it by reading the key back. SetProjExtState's own
// return cannot do that: it is the size of the whole extname's state, which `banks` alone
// keeps non-zero in every case a bake can reach. One extra read per written key, on a cold
// path. An empty `value` is a clear.
bool writeKeyVerified(ReaProject* proj, const std::string& key, const std::string& value) {
SetProjExtState(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return wire::bakeWriteLanded(value, readKey(proj, key).value);
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
@@ -390,6 +127,50 @@ private:
int flags_ = 0;
};
// One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into
// a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would
// skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make
// impossible. A throw is `Unknown` rather than a failure because the write may well have
// landed before the check did.
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
try {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the pass's persist runs after the whole scan,
// so until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
const wire::BakeLanding after =
wire::bakeLandingAfterPersist(entry.report.landing, persisted);
if (after == wire::BakeLanding::Unpersisted) {
entry.outcome = refuseBake(
BakeStatus::Failed,
"the bake reached the bank in memory, but this pass's persist did not "
"report success, so this answer cannot promise a reload will find it",
entry.outcome.generation);
// `detail` left empty on purpose: describeLanding's Unpersisted clause is
// that sentence's one home on the console side, and the message above is
// the instrument's own copy on the wire.
} else {
entry.report.detail = entry.outcome.message;
}
entry.report.landing = after;
value = wire::encodeBakeOutcome(entry.outcome);
}
SetProjExtState(entry.proj, kProjExtNamespace(), entry.key.c_str(), value.c_str());
// SetProjExtState's own return is the size of the whole extname's state, which
// `banks` alone keeps non-zero in every case a bake can reach, so only re-reading
// THIS key can speak for it. An oversized read proves nothing either way — folding
// it into bakeWriteLanded's absent case would report a clear off unread bytes.
const KeyRead back = readKey(entry.proj, entry.key);
if (back.oversized) return wire::BakeWriteProof::Unknown;
return wire::bakeWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
} catch (...) {
return wire::BakeWriteProof::Unknown;
}
}
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
@@ -412,7 +193,8 @@ void RunResampleBake(ReaSamplerSession& session) {
std::vector<ScannedKey> scanned;
wire::BakeScanTally tally; // every verdict below is counted, skips included
std::string aborted; // set only when the scan itself threw
bool persisted = false; // the pass's ONE persist actually happened
bool persisted = false; // the pass's ONE persist reported success
bool bookChanged = false; // some landing added or refreshed an entry
// The scan allocates outside attemptLanding's own guards too (readKey grows a buffer
// toward 16 MB, every container here allocates), and a throw that escaped would discard
// the answers already buffered — the exact no-answer-with-a-silent-console this action
@@ -465,25 +247,24 @@ void RunResampleBake(ReaSamplerSession& session) {
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
entry.outcome =
refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
request->generation);
refuseBake(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension "
"has loaded -- focus that tab and try again",
request->generation);
} else {
LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed;
// Unpersisted rather than Banked for anything that changed the book: the
// pass's persist has not run yet, and the write loop is where the
// upgrade is earned. A dedup hit changed nothing, so its landing is not
// this pass's to lose.
if (attempt.changedBook) bookChanged = true;
// Never Banked here, on ANY path: the pass's persist has not run yet,
// and bakeLandingAfterPersist is the one place that word is earned. A
// dedup hit is no exception — the entry it points at may be one this
// same pass added and is about to fail to persist.
if (attempt.partial)
entry.report.landing = wire::BakeLanding::Partial;
else if (attempt.outcome.status != BakeStatus::Ok)
entry.report.landing = wire::BakeLanding::Refused;
else
entry.report.landing = attempt.changedBook
? wire::BakeLanding::Unpersisted
: wire::BakeLanding::Banked;
entry.report.landing = wire::BakeLanding::Unpersisted;
entry.outcome = std::move(attempt.outcome);
}
++tally.answered; // QUEUED, not written — the write loop below judges that
@@ -493,56 +274,58 @@ void RunResampleBake(ReaSamplerSession& session) {
}
if (tally.landed > 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();
// A dedup hit rides the persist too, because that is the only observation that
// can promote its landing — but it added nothing, so it earns neither the
// refresh every open instance pays for nor an undo point named after a bake
// that banked nothing.
if (bookChanged) session.bumpBankGeneration();
persisted = session.saveToActiveProject();
if (persisted)
if (persisted && bookChanged)
undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG);
}
} catch (...) {
aborted =
"ReaSampler resample: the landing pass stopped early on an internal failure. "
"The answers it had already prepared are written below; any key whose answer it "
"had not prepared yet went unanswered and is not named at all.\n";
"The answers it had already prepared are reported below; any key whose answer "
"it had not prepared yet went unanswered and is not named at all.\n";
}
// OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
// undo point that captured it could restore the consumed request on the next Ctrl-Z —
// which the following bake would then re-land against a temp file that is long gone.
std::string console = aborted;
for (ScannedKey& entry : scanned) {
if (entry.write != ScannedKey::Write::None) {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the persist ran after the whole scan, so
// until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
if (entry.report.landing == wire::BakeLanding::Unpersisted) {
if (persisted)
entry.report.landing = wire::BakeLanding::Banked;
else
entry.outcome = refuse(BakeStatus::Failed,
"the bake reached the bank in memory, but "
"this pass could not persist it, so the "
"project's saved bank state does not carry it",
entry.outcome.generation);
try {
for (ScannedKey& entry : scanned) {
if (entry.write != ScannedKey::Write::None) {
entry.report.proof = writeBackOne(entry, persisted);
if (entry.write == ScannedKey::Write::Answer) {
if (entry.report.proof == wire::BakeWriteProof::Rejected)
++tally.writeFailed;
else if (entry.report.proof == wire::BakeWriteProof::Unknown)
++tally.writeUnproven;
}
value = wire::encodeBakeOutcome(entry.outcome);
entry.report.detail = entry.outcome.message;
}
entry.report.writeConfirmed = writeKeyVerified(entry.proj, entry.key, value);
if (!entry.report.writeConfirmed && entry.write == ScannedKey::Write::Answer)
++tally.writeFailed;
// Unconditional, including on success: the tally counts keys but cannot name
// them, and the key name is the only thing that tells an instance which line is
// its own.
console += wire::describeBakeKey(entry.key, entry.report);
}
console += wire::describeBakeScan(tally);
} catch (...) {
// Whatever lines already accumulated must still reach the console — that guarantee
// is this action's whole reason for existing. A second throw while appending the
// note below is swallowed for exactly the same reason: the print is what matters,
// and append gives the strong guarantee, so `console` survives either way.
try {
console +=
"ReaSampler resample: the write-back pass stopped early on an internal "
"failure. The lines above are everything it managed to report; any key not "
"named above may or may not have been answered.\n";
} catch (...) {
}
// Unconditional, including on success: the tally counts keys but cannot name them,
// and the key name is the only thing that tells an instance which line is its own.
console += wire::describeBakeKey(entry.key, entry.report);
}
console += wire::describeBakeScan(tally);
if (!console.empty()) ShowConsoleMsg(console.c_str());
if (tally.landed > 0) bankPanelRefresh();
if (bookChanged) bankPanelRefresh();
}
} // namespace reasampler::capture
+274
View File
@@ -0,0 +1,274 @@
// bake_landing.cpp — see bake_landing.h. Touches no REAPER API: a landing is filesystem +
// book + ledger only, which is what lets the scan pass own every ext-state read and write.
#include "shell/capture/bake_landing.h"
#include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional>
#include <system_error>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths
#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 "shell/persist/session.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;
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;
}
// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
std::optional<BakeOutcome> settled; // a refusal or a dedup hit: nothing left to commit
BakeOutcome outcome; // the shape a successful commit completes
Sample landed; // the entry the commit indexes
std::vector<std::uint8_t> bytes; // the staged file, already read
std::string bankId;
std::string destDir;
std::string destPath;
bool replace = false;
};
PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
PreparedLanding prep;
if (projectDir.empty()) {
prep.settled = refuseBake(BakeStatus::NoProject,
"no saved project, so the bank has no location",
request.generation);
return prep;
}
prep.bytes = util::readFileBytes(request.stagedFilePath);
if (prep.bytes.empty()) {
prep.settled = refuseBake(BakeStatus::StagedMissing,
"the staged render was unreadable", request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuseBake(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
return prep;
}
BankBook& book = session.book();
const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
if (!source) {
prep.settled = refuseBake(BakeStatus::NoSource,
"the resampled capture is not in any bank",
request.generation);
return prep;
}
// Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
prep.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(prep.bytes);
prep.outcome.generation = request.generation;
prep.outcome.rootNote = request.rootNote;
prep.outcome.channelCount = static_cast<int>(layout.channelCount);
prep.outcome.replaced = prep.replace;
if (!prep.replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(prep.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. It still leaves `changedBook` false
// and still rides the pass's persist — the entry it points at may be one
// the same pass added moments ago.
BakeOutcome out = prep.outcome;
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";
prep.settled = std::move(out);
return prep;
}
}
}
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);
prep.destDir = paths.absoluteDir;
prep.destPath = paths.absoluteDir + "/" + paths.fileName;
// 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.
prep.landed.id =
prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
prep.landed.displayName =
prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
prep.landed.relativePath = paths.relativePath; // project-relative (invariant)
prep.landed.channelCount = static_cast<int>(layout.channelCount);
prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
prep.landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
// Rendered AT root — that is what makes it survive.
prep.landed.rootNote = request.rootNote;
prep.landed.tier = model::Tier::Scratch;
prep.landed.contentHash = contentHash;
prep.landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
return prep;
}
// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
std::error_code ec;
fs::create_directories(prep.destDir, ec); // idempotent; the write reports failure
if (!writeFileBytes(prep.destPath, prep.bytes)) {
// A failed write can still have created the file before failing; withdraw it for
// the same reason the refused index below does.
fs::remove(prep.destPath, ec);
return refuseBake(BakeStatus::Failed,
"could not write the bake into the bank folder",
prep.outcome.generation);
}
BankBook& book = session.book();
// On the replace path prepareLanding set `landed.id` to the source's own id, so this is
// the entry being refreshed in place.
const bool indexed =
prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
: (book.index(prep.bankId) &&
book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(prep.destPath, ec);
return refuseBake(BakeStatus::IndexRejected,
prep.replace ? "the bank refused the replacement"
: "the bank refused the new capture",
prep.outcome.generation);
}
session.recordCreated(prep.landed, tracking::OriginKind::Capture);
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = prep.landed.id;
out.relativePath = prep.landed.relativePath;
out.displayName = prep.landed.displayName;
out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
} // namespace
BakeOutcome refuseBake(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
out.message = std::move(message);
out.generation = generation;
return out;
}
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
LandingAttempt attempt;
PreparedLanding prep;
try {
prep = prepareLanding(session, projectDir, request);
} catch (const std::exception& e) {
attempt.outcome = refuseBake(
BakeStatus::Failed,
std::string("the landing failed before it wrote anything: ") + e.what(),
request.generation);
return attempt;
} catch (...) {
attempt.outcome = refuseBake(
BakeStatus::Failed,
"the landing failed before it wrote anything, for an unknown reason",
request.generation);
return attempt;
}
if (prep.settled) {
attempt.outcome = std::move(*prep.settled);
return attempt; // a refusal, or a dedup hit that changed nothing
}
try {
attempt.outcome = commitLanding(session, prep);
attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
} catch (const std::exception& e) {
attempt.partial = true;
attempt.outcome =
refuseBake(BakeStatus::Failed,
std::string("the landing failed while writing: ") + e.what(),
request.generation);
} catch (...) {
attempt.partial = true;
attempt.outcome = refuseBake(BakeStatus::Failed,
"the landing failed while writing, for an unknown reason",
request.generation);
}
return attempt;
}
} // namespace reasampler::capture
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// bake_landing — landing ONE bake request into the loaded project's bank: read and hash the
// staged WAV, resolve replace-vs-add, write the file, index it, seed its lineage. The scan
// pass that finds requests across the open tabs and answers them is `bake_land`; this is
// what it calls per request, and it neither reads nor writes an ext-state key.
#include <cstdint>
#include <string>
#include "core/wire/bake_wire.h"
namespace reasampler {
class ReaSamplerSession;
}
namespace reasampler::capture {
// A refusal outcome shaped for the key it will be written back to. Shared with the scan
// pass, which refuses on grounds no single landing can see — a request from another tab, or
// a landing the pass then failed to persist.
wire::BakeOutcome refuseBake(wire::BakeStatus status, std::string message,
std::int64_t generation);
// One request landed, with the two failure truths kept apart: a throw out of the prepare
// half is a refusal that left the project untouched, while a throw out of the commit half
// may have written a file and changed the book — calling that one "refused" would tell the
// user nothing happened when something did.
struct LandingAttempt {
wire::BakeOutcome outcome;
bool partial = false; // the commit threw: a file and/or an entry may exist
bool changedBook = false; // the book gained or refreshed an entry, unpersisted so far
};
// Never throws: both halves run under their own catch, and which half threw is what
// `partial` carries. Mutates book + ledger WITHOUT persisting — the caller persists once
// for its whole batch, which is why no landing may report itself as banked.
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const wire::BakeRequest& request);
} // namespace reasampler::capture