bake: name the five ways the extension can fail to answer, and move the landing verdict into a pure, tab-provable classifier

A no-answer stays a failure; it now says whether the extension never ran the landing, answered a stale generation, spoke a wire this build cannot read, cleared the request, or refused it.
This commit is contained in:
2026-08-02 06:38:04 -04:00
parent 6e937b9c61
commit 962ab64ef0
8 changed files with 373 additions and 48 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ 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. 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: 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. 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.
- `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).
+18 -27
View File
@@ -6,7 +6,6 @@
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
@@ -53,13 +52,6 @@ using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
// The whole chain is a call and a return inside ONE editor tick, so a request older than
// this has no reader left: it is a crash leftover, and it is CLEARED rather than landed.
// Without the guard a stranded request would be banked on the next unrelated instance's
// click, and the outcome written back to a key nobody will collect would persist into the
// .rpp forever.
constexpr std::int64_t kMaxRequestAgeSeconds = 30;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
@@ -268,15 +260,12 @@ struct Answer {
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
// Three projects have to agree before anything may land: the tab the request was found
// in, the tab whose book/ledger poll() last loaded, and the tab saveToActiveProject
// will persist into. Land on a disagreement and one tab's bake is written into
// another's bank. Whether REAPER makes Main_OnCommandEx's `proj` current for the
// action's duration is unverified in the DAW; this holds either way, and a request it
// cannot land is told why rather than silently ignored.
// Whether REAPER makes Main_OnCommandEx's `proj` current for the action's duration is
// unverified in the DAW; the per-key verdict holds either way, and a request it cannot
// land is told why rather than silently ignored.
const void* loaded = session.loadedProject();
const void* active = EnumProjects(-1, nullptr, 0);
const bool sessionUsable = loaded != nullptr && loaded == active;
const wire::BakeScanContext scanContext{loaded != nullptr, loaded == active};
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers;
@@ -285,21 +274,23 @@ void RunResampleBake(ReaSamplerSession& session) {
for (const OpenProject& open : openProjects()) {
for (const std::string& key : pendingBakeKeys(open.proj)) {
const std::optional<std::string> raw = readKey(open.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 key.
if (!request) continue;
const std::optional<BakeRequest> request =
raw ? wire::decodeBakeRequest(*raw) : std::nullopt;
const wire::BakeScanKey scanKey{
request.has_value(), request ? request->generation : 0,
static_cast<const void*>(open.proj) == loaded};
const wire::BakeScanVerdict verdict =
wire::classifyBakeScan(scanContext, scanKey, nowSec);
// Stale (either direction, so a clock moved backwards is caught too): clear the
// key, never answer it. The instance that could read an answer is gone.
if (std::llabs(nowSec - request->generation) > kMaxRequestAgeSeconds) {
// Leave it — the writing instance owns clearing its own key.
if (verdict == wire::BakeScanVerdict::Ignore) continue;
if (verdict == wire::BakeScanVerdict::ClearStale) {
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
continue;
}
BakeOutcome outcome;
if (!sessionUsable || static_cast<const void*>(open.proj) != loaded) {
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
outcome = refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
@@ -310,9 +301,9 @@ void RunResampleBake(ReaSamplerSession& session) {
}
// A WrongProject request left sitting in a tab this call did not come from
// prints again on every OTHER tab's bake, since the scan revisits every open
// project each time. `active` is this call's own proxy for "the invoking tab"
// (see the three-projects comment above) — only that tab's own refusal is fresh
// feedback to a user who just clicked bake; every other one is a rescan repeat.
// project each time. `active` is this call's own proxy for "the invoking tab"
// only that tab's own refusal is fresh feedback to a user who just clicked
// bake; every other one is a rescan repeat.
const bool ownRequest = static_cast<const void*>(open.proj) == active;
std::string console;
if (outcome.status != BakeStatus::Ok &&