Ξ-W2-T1 remediation: print master gain into the bake, derive the window from the dialed sound, reset play mode to Trigger

This commit is contained in:
2026-08-01 17:05:28 -04:00
parent 60308a3655
commit 39c2d1cdb4
31 changed files with 679 additions and 201 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ detail not covered there:
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
- `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: lands every pending `rsbake_*` request in the active project and answers over the same 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".
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: lands every pending `rsbake_*` request in the active project and answers over the same 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.
- `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).
+98 -31
View File
@@ -6,11 +6,13 @@
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
@@ -51,6 +53,13 @@ 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;
@@ -59,12 +68,6 @@ BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generati
return out;
}
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
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;
@@ -85,11 +88,12 @@ const Sample* findSourceSample(const BankBook& book, const std::string& sampleId
return nullptr;
}
// Lands ONE request. Mutates the book + ledger without persisting; the caller persists
// once for the batch. Every refusal path leaves the book untouched and writes no file, so
// a failed bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
const std::string projectDir = currentProjectDir();
// Lands ONE request into `session`'s book, which must already be the book of the project
// `projectDir` names. Mutates book + ledger without persisting; the caller persists once for
// the batch. Every refusal path leaves the book untouched and writes no file, so a failed
// bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
if (projectDir.empty())
return refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location", request.generation);
@@ -190,9 +194,9 @@ BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
: (book.index(bankId) &&
book.index(bankId)->add(landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — not the
// prune's deletion authority, which governs files the bank knows about. Leaving it
// would deposit an untracked orphan for every refused bake.
// 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(destPath, ec);
return refuse(BakeStatus::IndexRejected,
replace ? "the bank refused the replacement"
@@ -209,7 +213,7 @@ BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
return out;
}
// Every "rsbake_*" key in the project, keys only — the value can outgrow
// 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) {
std::vector<std::string> keys;
@@ -234,28 +238,82 @@ std::optional<std::string> readKey(ReaProject* proj, const std::string& key) {
return read.value;
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
};
// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
std::vector<OpenProject> out;
std::vector<char> buf(4096, '\0');
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
if (!proj) break;
out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
}
return out;
}
// One answer to write back after the undo block closes.
struct Answer {
ReaProject* proj = nullptr;
std::string key;
std::string wire; // empty = clear the key instead of answering it
std::string console; // empty = nothing to print
};
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (!proj) return;
// 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.
const void* loaded = session.loadedProject();
const void* active = EnumProjects(-1, nullptr, 0);
const bool sessionUsable = loaded != nullptr && loaded == active;
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers;
int landedCount = 0;
Undo_BeginBlock2(nullptr);
for (const std::string& key : pendingBakeKeys(proj)) {
const std::optional<std::string> raw = readKey(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 own key.
if (!request) continue;
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 BakeOutcome outcome = landOne(session, *request);
if (outcome.status == BakeStatus::Ok) ++landedCount;
SetProjExtState(proj, kProjExtNamespace(), key.c_str(),
wire::encodeBakeOutcome(outcome).c_str());
if (outcome.status != BakeStatus::Ok)
ShowConsoleMsg(("ReaSampler resample: " + outcome.message + ".\n").c_str());
// 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) {
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
continue;
}
BakeOutcome outcome;
if (!sessionUsable || static_cast<const void*>(open.proj) != loaded) {
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) ++landedCount;
}
answers.push_back(Answer{
open.proj, key, wire::encodeBakeOutcome(outcome),
outcome.status == BakeStatus::Ok
? std::string{}
: "ReaSampler resample: " + outcome.message + ".\n"});
}
}
if (landedCount > 0) {
@@ -266,10 +324,19 @@ void RunResampleBake(ReaSamplerSession& session) {
Undo_EndBlock2(nullptr,
persisted ? "ReaSampler: resample bake into bank" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
bankPanelRefresh();
} else {
Undo_EndBlock2(nullptr, "", 0); // nothing landed — record no empty undo point
}
// 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());
}
if (landedCount > 0) bankPanelRefresh();
}
} // namespace reasampler::capture
+6 -4
View File
@@ -16,10 +16,12 @@ class ReaSamplerSession;
namespace reasampler::capture {
// The bake action's body: lands every pending "rsbake_*" request in the active project,
// one undo point for the batch. Normally there is exactly one — the instance that just
// invoked us. Runs synchronously inside the invoking instance's Main_OnCommandEx call, so
// the answer is available to it the moment this returns.
// The bake action's body: scans EVERY open project tab for pending "rsbake_*" requests and
// lands the ones belonging to the project this session has loaded, one undo point for the
// batch. Normally there is exactly one — the instance that just invoked us. Runs
// synchronously inside that instance's Main_OnCommandEx call, so the answer is available to
// it the moment this returns. A request from another tab is refused, and one too old to
// have a reader left is cleared rather than answered.
void RunResampleBake(ReaSamplerSession& session);
} // namespace reasampler::capture