Fix the bake scan report's unsound absence-as-signal claim
Gate the summary on any unanswered key and on SetProjExtState's return, print one line per scanned key, and make the undo block stack RAII.
This commit is contained in:
@@ -57,7 +57,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 — 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.
|
||||
- `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 checks `SetProjExtState`'s return on every non-empty answer — 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. The undo block is stack RAII (`UndoBlock`), and `landOne`'s whole-WAV read/hash runs under a catch that converts a throw into a refusal — 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.
|
||||
- `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).
|
||||
|
||||
+168
-80
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
@@ -222,12 +223,22 @@ std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
std::optional<std::string> readKey(ReaProject* proj, const std::string& key) {
|
||||
// Whole value, or the reason there isn't one. Absent and Overflow both leave the scan with
|
||||
// nothing to decode — one verdict — but they are different faults to go fix, so the reason
|
||||
// survives to the per-key console line rather than collapsing here.
|
||||
struct KeyRead {
|
||||
std::optional<std::string> value;
|
||||
bool oversized = false; // the value never fit under the grow loop's ceiling
|
||||
};
|
||||
|
||||
KeyRead readKey(ReaProject* proj, const std::string& key) {
|
||||
const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
|
||||
return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
|
||||
});
|
||||
if (read.status != wire::GrowingExtStateRead::Status::Complete) return std::nullopt;
|
||||
return read.value;
|
||||
KeyRead out;
|
||||
out.oversized = read.status == wire::GrowingExtStateRead::Status::Overflow;
|
||||
if (read.status == wire::GrowingExtStateRead::Status::Complete) out.value = read.value;
|
||||
return out;
|
||||
}
|
||||
|
||||
struct OpenProject {
|
||||
@@ -249,21 +260,49 @@ std::vector<OpenProject> openProjects() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// One answer to write back after the undo block closes.
|
||||
struct Answer {
|
||||
// One scanned key: what the pass decided about it, and the answer to write back after the
|
||||
// undo block closes.
|
||||
struct ScannedKey {
|
||||
ReaProject* proj = nullptr;
|
||||
std::string key;
|
||||
std::string wire; // empty = clear the key instead of answering it
|
||||
std::string console; // empty = nothing to print
|
||||
bool writesAnswer = false; // false = an Ignore verdict, nothing to write
|
||||
std::string answerWire; // with writesAnswer: empty = clear the key instead
|
||||
std::string console; // the outcome's own sentence; empty = nothing to print
|
||||
wire::BakeKeyOutcome report;
|
||||
};
|
||||
|
||||
// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: landOne reads a
|
||||
// whole WAV and hashes it, so a bad_alloc between the two calls would otherwise leave an
|
||||
// open undo block in the user's project. Closing with an empty description records no undo
|
||||
// point, which is what every path that landed nothing wants anyway.
|
||||
class UndoBlock {
|
||||
public:
|
||||
UndoBlock() { Undo_BeginBlock2(nullptr); }
|
||||
~UndoBlock() { Undo_EndBlock2(nullptr, description_.c_str(), flags_); }
|
||||
void recordPoint(std::string description, int flags) {
|
||||
description_ = std::move(description);
|
||||
flags_ = flags;
|
||||
}
|
||||
UndoBlock(const UndoBlock&) = delete;
|
||||
UndoBlock& operator=(const UndoBlock&) = delete;
|
||||
|
||||
private:
|
||||
std::string description_;
|
||||
int flags_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void RunResampleBake(ReaSamplerSession& session) {
|
||||
// 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();
|
||||
// REAPER's CURRENT tab, which is all this is. The action was invoked with the asking
|
||||
// instance's own project as `proj` — getReaperParent(3), documented as the parent
|
||||
// project in reaper_vst3_interfaces.h, and already paired with an EnumProjects-derived
|
||||
// enumeration by the shipped rsusage_* path prune's protection depends on. What is
|
||||
// `[verify — DAW]` is only whether REAPER makes that `proj` CURRENT for the action's
|
||||
// duration, so nothing below may call this "the tab the bake was fired against". The
|
||||
// per-key verdict holds either way, and a request this pass cannot land is told why
|
||||
// rather than silently ignored.
|
||||
const void* active = EnumProjects(-1, nullptr, 0);
|
||||
// `loaded == active` alone is enough: every OpenProject::proj enumerated below is a
|
||||
// real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and
|
||||
@@ -271,88 +310,137 @@ void RunResampleBake(ReaSamplerSession& session) {
|
||||
const wire::BakeScanContext scanContext{loaded == active};
|
||||
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
std::vector<Answer> answers;
|
||||
std::vector<ScannedKey> scanned;
|
||||
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{
|
||||
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);
|
||||
std::string aborted; // set only when the scan itself threw
|
||||
// The scan allocates outside landOne's own guard 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
|
||||
// exists to make impossible. UndoBlock is inside the try, so unwinding still closes it.
|
||||
try {
|
||||
UndoBlock undo;
|
||||
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 KeyRead read = readKey(open.proj, key);
|
||||
const std::optional<BakeRequest> request =
|
||||
read.value ? wire::decodeBakeRequest(*read.value) : std::nullopt;
|
||||
const wire::BakeScanKey scanKey{
|
||||
read.value.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::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;
|
||||
}
|
||||
ScannedKey entry;
|
||||
entry.proj = open.proj;
|
||||
entry.key = key;
|
||||
entry.report.verdict = verdict;
|
||||
entry.report.oversized = read.oversized;
|
||||
|
||||
BakeOutcome outcome;
|
||||
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",
|
||||
request->generation);
|
||||
} else {
|
||||
outcome = landOne(session, open.dir, *request);
|
||||
if (outcome.status == BakeStatus::Ok) ++tally.landed;
|
||||
// Leave it — the writing instance owns clearing its own key.
|
||||
if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) {
|
||||
++tally.unreadable;
|
||||
scanned.push_back(std::move(entry));
|
||||
continue;
|
||||
}
|
||||
// `!request` is folded in here rather than dereferenced below: today
|
||||
// classifyBakeScan filters every undecoded key into this verdict, but a
|
||||
// future verdict that did not would otherwise reach *request unguarded.
|
||||
if (verdict == wire::BakeScanVerdict::IgnoreNotARequest || !request) {
|
||||
++tally.notARequest;
|
||||
entry.report.verdict = wire::BakeScanVerdict::IgnoreNotARequest;
|
||||
scanned.push_back(std::move(entry));
|
||||
continue;
|
||||
}
|
||||
if (verdict == wire::BakeScanVerdict::ClearStale) {
|
||||
++tally.staleCleared;
|
||||
entry.writesAnswer = true; // empty answerWire = clear the key
|
||||
scanned.push_back(std::move(entry));
|
||||
continue;
|
||||
}
|
||||
|
||||
BakeOutcome outcome;
|
||||
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",
|
||||
request->generation);
|
||||
} else {
|
||||
// landOne reads and hashes a whole WAV. A throw here would otherwise
|
||||
// discard every answer buffered so far and leave each asking instance
|
||||
// with a no-answer it cannot explain; converting it to a refusal keeps
|
||||
// the pass and its report intact.
|
||||
try {
|
||||
outcome = landOne(session, open.dir, *request);
|
||||
} catch (const std::exception& e) {
|
||||
outcome = refuse(BakeStatus::Failed,
|
||||
std::string("the landing failed: ") + e.what(),
|
||||
request->generation);
|
||||
} catch (...) {
|
||||
outcome = refuse(BakeStatus::Failed,
|
||||
"the landing failed for an unknown reason",
|
||||
request->generation);
|
||||
}
|
||||
if (outcome.status == BakeStatus::Ok) ++tally.landed;
|
||||
}
|
||||
++tally.answered; // QUEUED, not written — the write loop below judges that
|
||||
entry.report.landed = outcome.status == BakeStatus::Ok;
|
||||
// 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. Only a refusal in REAPER's active tab is likely
|
||||
// to be fresh feedback to a user who just clicked bake; every other one is
|
||||
// a rescan repeat. The per-key line below is printed either way.
|
||||
if (outcome.status != BakeStatus::Ok &&
|
||||
(outcome.status != BakeStatus::WrongProject || activeTab)) {
|
||||
entry.console = "ReaSampler resample: " + outcome.message + ".\n";
|
||||
}
|
||||
entry.writesAnswer = true;
|
||||
entry.answerWire = wire::encodeBakeOutcome(outcome);
|
||||
scanned.push_back(std::move(entry));
|
||||
}
|
||||
++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.
|
||||
std::string console;
|
||||
if (outcome.status != BakeStatus::Ok &&
|
||||
(outcome.status != BakeStatus::WrongProject || activeTab)) {
|
||||
console = "ReaSampler resample: " + outcome.message + ".\n";
|
||||
}
|
||||
answers.push_back(
|
||||
Answer{open.proj, key, wire::encodeBakeOutcome(outcome), console});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const bool persisted = session.saveToActiveProject();
|
||||
Undo_EndBlock2(nullptr,
|
||||
persisted ? "ReaSampler: resample bake into bank" : "",
|
||||
persisted ? UNDO_STATE_MISCCFG : 0);
|
||||
} else {
|
||||
Undo_EndBlock2(nullptr, "", 0); // nothing landed — record no empty undo point
|
||||
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();
|
||||
if (session.saveToActiveProject())
|
||||
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 it had not "
|
||||
"reached 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.
|
||||
for (const Answer& answer : answers) {
|
||||
SetProjExtState(answer.proj, kProjExtNamespace(), answer.key.c_str(),
|
||||
answer.wire.c_str());
|
||||
if (!answer.console.empty()) ShowConsoleMsg(answer.console.c_str());
|
||||
std::string console = aborted;
|
||||
for (ScannedKey& entry : scanned) {
|
||||
if (entry.writesAnswer) {
|
||||
// SetProjExtState returns the size of the extname's state, so storing a
|
||||
// non-empty value necessarily returns > 0 and <= 0 means the write did not
|
||||
// land. A deliberate clear shrinks the state and can legitimately return 0 —
|
||||
// the same reading reaper_bridge's writeGuarded applies to the other end.
|
||||
const int rv = SetProjExtState(entry.proj, kProjExtNamespace(),
|
||||
entry.key.c_str(), entry.answerWire.c_str());
|
||||
entry.report.answerWritten = entry.answerWire.empty() || rv > 0;
|
||||
if (!entry.report.answerWritten) ++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 += entry.console;
|
||||
}
|
||||
// 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());
|
||||
console += wire::describeBakeScan(tally);
|
||||
if (!console.empty()) ShowConsoleMsg(console.c_str());
|
||||
if (tally.landed > 0) bankPanelRefresh();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user