bake: make the landing scan say what it saw, so a no-answer names its own cause
Splits Ignore into unreadable vs not-a-request and counts every verdict; the report prints only when the pass answered nobody, so its absence proves the action never ran.
This commit is contained in:
@@ -80,7 +80,7 @@ This directory owns two cross-artifact contracts specifically:
|
||||
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
|
||||
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
|
||||
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
|
||||
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, is what separates an extension that never ran the landing from one that refused) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / Ignore verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable).
|
||||
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, is what separates an extension that never ran the landing from one that refused) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` are that same reading counted and spoken — the rationale lives at the type. The report is empty whenever a pass answered anybody, which makes its ABSENCE from the console evidence too: no line means the landing action never ran.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ BakeStatus statusFromWire(int raw) {
|
||||
return BakeStatus::Failed;
|
||||
}
|
||||
|
||||
std::string countOf(int n, const char* noun) {
|
||||
return std::to_string(n) + " " + noun + (n == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string bakeActionLookupName() {
|
||||
@@ -160,9 +164,14 @@ const BakeOutcome* answeredOutcome(const BakeAnswer& answer) {
|
||||
|
||||
BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key,
|
||||
std::int64_t nowSec) {
|
||||
// Listed by the enumerator but not returned whole by the reader. Distinct from the
|
||||
// next case even though both leave the key alone: this one is the extension failing
|
||||
// to read a request that may well be there, and it is invisible from the other end.
|
||||
if (!key.readable) return BakeScanVerdict::IgnoreUnreadable;
|
||||
|
||||
// Not a request: an outcome the writing instance has not collected yet, or a value
|
||||
// from a build we do not read. The writer owns clearing its own key.
|
||||
if (!key.decoded) return BakeScanVerdict::Ignore;
|
||||
if (!key.decoded) return BakeScanVerdict::IgnoreNotARequest;
|
||||
|
||||
// Either direction, so a clock moved backwards is caught too.
|
||||
const std::int64_t age = nowSec - key.generation;
|
||||
@@ -177,4 +186,31 @@ BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanK
|
||||
return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject;
|
||||
}
|
||||
|
||||
std::string describeBakeScan(const BakeScanTally& t) {
|
||||
if (t.answered > 0) return {};
|
||||
|
||||
std::string s = "ReaSampler resample: the landing action ran and scanned " +
|
||||
countOf(t.tabsScanned, "project tab") + ", and answered nothing. ";
|
||||
if (t.keysFound == 0) {
|
||||
s += "No pending bake request was visible to it at all -- if ReaSampler 9000 "
|
||||
"reported publishing one, the plugin and the extension are not reading the "
|
||||
"same project's ext state.";
|
||||
return s + "\n";
|
||||
}
|
||||
|
||||
s += "It found " + countOf(t.keysFound, "pending request key") + ", " +
|
||||
std::to_string(t.activeTabKeys) + " of them in the active tab";
|
||||
if (t.unreadable > 0)
|
||||
s += "; " + std::to_string(t.unreadable) + " could not be read back";
|
||||
if (t.notARequest > 0)
|
||||
s += "; " + std::to_string(t.notARequest) + " held something other than a request";
|
||||
if (t.staleCleared > 0)
|
||||
s += "; " + std::to_string(t.staleCleared) +
|
||||
" were past the age bound and were cleared unanswered";
|
||||
s += ".";
|
||||
if (t.activeTabKeys == 0)
|
||||
s += " The tab this bake was fired against held none of them.";
|
||||
return s + "\n";
|
||||
}
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
@@ -127,7 +127,8 @@ enum class BakeScanVerdict {
|
||||
Land, // land it into the loaded project's bank
|
||||
RefuseWrongProject, // answer WrongProject — the book in memory belongs to another tab
|
||||
ClearStale, // no reader left: clear the key, never answer it
|
||||
Ignore, // not a request (an uncollected outcome, or a wire we do not read)
|
||||
IgnoreUnreadable, // the enumerator listed it, the reader could not return it whole
|
||||
IgnoreNotARequest, // read whole, but an uncollected outcome or a wire we do not read
|
||||
};
|
||||
|
||||
// The session's side of the verdict: whether the loaded project is the one REAPER will
|
||||
@@ -143,7 +144,8 @@ struct BakeScanContext {
|
||||
// The scanned key's side — per-TAB, which is what makes a request found in a background
|
||||
// tab decidable without any REAPER type crossing into this module.
|
||||
struct BakeScanKey {
|
||||
bool decoded = false; // the value decoded as a BakeRequest
|
||||
bool readable = false; // the enumerated key's value came back WHOLE
|
||||
bool decoded = false; // that value decoded as a BakeRequest (implies readable)
|
||||
std::int64_t generation = 0;
|
||||
bool matchesLoadedProject = false; // this key's tab IS the session's loaded project
|
||||
// — false whenever the session has no loaded
|
||||
@@ -153,4 +155,26 @@ struct BakeScanKey {
|
||||
BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key,
|
||||
std::int64_t nowSec);
|
||||
|
||||
// What ONE scan pass actually saw, accumulated by the shell as it applies the verdicts
|
||||
// above. Counts only, so the report below is provable without a DAW. It exists because
|
||||
// every non-answering verdict leaves the asking instance with the same evidence — its own
|
||||
// request, untouched — and only these counts say which of them happened.
|
||||
struct BakeScanTally {
|
||||
int tabsScanned = 0;
|
||||
int keysFound = 0; // `rsbake_*` keys enumerated across every open tab
|
||||
int activeTabKeys = 0; // of those, in the tab the action was fired against
|
||||
int unreadable = 0; // IgnoreUnreadable
|
||||
int notARequest = 0; // IgnoreNotARequest
|
||||
int staleCleared = 0; // ClearStale
|
||||
int answered = 0; // an outcome was written back (a landing OR a refusal)
|
||||
int landed = 0; // of `answered`, the ones that reached the bank
|
||||
};
|
||||
|
||||
// The console sentence for a pass that answered NOTHING — the one state in which the
|
||||
// asking instance reports a no-answer and has nothing to go on. Empty string when
|
||||
// `answered > 0`, so the caller prints unconditionally and stays quiet on a pass that
|
||||
// spoke for itself. The line's absence is itself evidence: no line means the landing
|
||||
// action never ran.
|
||||
std::string describeBakeScan(const BakeScanTally& tally);
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
@@ -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. 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.
|
||||
- `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, and printing `wire::describeBakeScan` when the pass answered nobody. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. 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).
|
||||
|
||||
@@ -272,22 +272,34 @@ void RunResampleBake(ReaSamplerSession& session) {
|
||||
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
std::vector<Answer> answers;
|
||||
int landedCount = 0;
|
||||
wire::BakeScanTally tally; // every verdict below is counted, skips included
|
||||
Undo_BeginBlock2(nullptr);
|
||||
for (const OpenProject& open : openProjects()) {
|
||||
++tally.tabsScanned;
|
||||
const bool activeTab = static_cast<const void*>(open.proj) == active;
|
||||
for (const std::string& key : pendingBakeKeys(open.proj)) {
|
||||
++tally.keysFound;
|
||||
if (activeTab) ++tally.activeTabKeys;
|
||||
const std::optional<std::string> raw = readKey(open.proj, key);
|
||||
const std::optional<BakeRequest> request =
|
||||
raw ? wire::decodeBakeRequest(*raw) : std::nullopt;
|
||||
const wire::BakeScanKey scanKey{
|
||||
request.has_value(), request ? request->generation : 0,
|
||||
raw.has_value(), request.has_value(), request ? request->generation : 0,
|
||||
static_cast<const void*>(open.proj) == loaded};
|
||||
const wire::BakeScanVerdict verdict =
|
||||
wire::classifyBakeScan(scanContext, scanKey, nowSec);
|
||||
|
||||
// Leave it — the writing instance owns clearing its own key.
|
||||
if (verdict == wire::BakeScanVerdict::Ignore) continue;
|
||||
if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) {
|
||||
++tally.unreadable;
|
||||
continue;
|
||||
}
|
||||
if (verdict == wire::BakeScanVerdict::IgnoreNotARequest) {
|
||||
++tally.notARequest;
|
||||
continue;
|
||||
}
|
||||
if (verdict == wire::BakeScanVerdict::ClearStale) {
|
||||
++tally.staleCleared;
|
||||
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
|
||||
continue;
|
||||
}
|
||||
@@ -300,17 +312,17 @@ void RunResampleBake(ReaSamplerSession& session) {
|
||||
request->generation);
|
||||
} else {
|
||||
outcome = landOne(session, open.dir, *request);
|
||||
if (outcome.status == BakeStatus::Ok) ++landedCount;
|
||||
if (outcome.status == BakeStatus::Ok) ++tally.landed;
|
||||
}
|
||||
++tally.answered;
|
||||
// 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" —
|
||||
// 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 &&
|
||||
(outcome.status != BakeStatus::WrongProject || ownRequest)) {
|
||||
(outcome.status != BakeStatus::WrongProject || activeTab)) {
|
||||
console = "ReaSampler resample: " + outcome.message + ".\n";
|
||||
}
|
||||
answers.push_back(
|
||||
@@ -318,7 +330,7 @@ void RunResampleBake(ReaSamplerSession& session) {
|
||||
}
|
||||
}
|
||||
|
||||
if (landedCount > 0) {
|
||||
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();
|
||||
@@ -338,7 +350,10 @@ void RunResampleBake(ReaSamplerSession& session) {
|
||||
answer.wire.c_str());
|
||||
if (!answer.console.empty()) ShowConsoleMsg(answer.console.c_str());
|
||||
}
|
||||
if (landedCount > 0) bankPanelRefresh();
|
||||
// Empty unless the pass answered nobody, so a bake that spoke for itself stays quiet.
|
||||
const std::string report = wire::describeBakeScan(tally);
|
||||
if (!report.empty()) ShowConsoleMsg(report.c_str());
|
||||
if (tally.landed > 0) bankPanelRefresh();
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -204,14 +204,10 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
break;
|
||||
case wire::BakeAnswerKind::Unanswered:
|
||||
return fail(
|
||||
"the ReaSampler extension did not run the bake landing -- its action id "
|
||||
"resolved but nothing read the request. Possible causes: the extension is "
|
||||
"older than this plugin and does not know this action; REAPER deferred "
|
||||
"running the action past this call returning (unverified -- see "
|
||||
"reaper_bridge.h); the extension ran but could not read its own request "
|
||||
"key on this pass; or the action name resolved to an id no currently "
|
||||
"loaded extension actually handles. Reinstalling the extension and "
|
||||
"restarting REAPER is worth trying, but is not the only possible fix");
|
||||
"the ReaSampler extension did not answer -- the request key still holds "
|
||||
"this exact request, untouched. Check the REAPER console: if the landing "
|
||||
"action printed a scan report just now, it DID run and that line says what "
|
||||
"it saw; if the console is silent, the action never ran at all");
|
||||
case wire::BakeAnswerKind::Undecodable:
|
||||
return fail(
|
||||
"the extension answered in a format this plugin does not read -- the "
|
||||
@@ -219,7 +215,8 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
case wire::BakeAnswerKind::Cleared:
|
||||
return fail(
|
||||
"the bake key came back empty -- either the request was cleared before "
|
||||
"an answer was written, or this build could not read whatever was there");
|
||||
"an answer was written, or this build could not read whatever was there. "
|
||||
"The landing action's console scan report, if one appeared, names which");
|
||||
case wire::BakeAnswerKind::ForeignRequest:
|
||||
return fail(
|
||||
"the bake key held a different pending request instead of an answer -- "
|
||||
|
||||
Reference in New Issue
Block a user