Cut shell/capture comment bloat ~33% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:48:59 -04:00
parent 1f24c4b095
commit 54f5f24506
22 changed files with 699 additions and 1215 deletions
+109 -261
View File
@@ -1,38 +1,22 @@
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus // REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared
// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders). // backend helpers (makeUniqueTag / stampCaptureSample).
// //
// Compiled into the reaper_reasampler MODULE. Includes // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU // the one TU that defines the API pointers; here they are extern.
// that defines the API pointers; here they are extern (CLAUDE.md §contract).
// //
// Renders a CaptureRequest's source over its requested range. The full three-scope // Drives the RENDER_* project settings via GetSetProjectInfo/_String (source-
// capture family (item / track / master, each over a razor-else-time range) is // selection bits come from the pure render_settings mapping), snapshots and
// driven here — all wet-only with optional tail. FX scope is enforced by the // restores every setting it changes, triggers a render, then populates a Sample.
// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture; // Source-agnostic: never reads the DAW selection itself, only the CaptureRequest
// this backend is source-agnostic and does not itself read the DAW selection. // the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never
// Drives the RENDER_* project settings via GetSetProjectInfo / _String // inserts into the arrange.
// (the source-selection bits come from render_settings.cpp, the pure mapping),
// snapshots and restores every setting it changes (non-destructive), triggers a
// render, then populates a Sample. It NEVER inserts into the arrange
// (load-bearing principle) — RENDER_ADDTOPROJ&1 is cleared on every path.
// //
// The backend is SOURCE-AGNOSTIC: it does NOT read the DAW selection. The action // RENDER PROGRESS WINDOW: triggering kActionRenderUsingMostRecentSettings (42230)
// layer (main.cpp) resolves each source mode to a concrete time range (+ track // shows REAPER's offline-render progress dialog for the render's duration; no
// GUIDs for track captures) and hands it in via the CaptureRequest. This keeps // RENDER_SETTINGS bit or GetSetProjectInfo desc suppresses it — inherent to
// the render-driving here and the selection-reading testable/visible up in the // REAPER's offline render path. The dialog-free alternative is the realtime-
// actions layer. // record backend, which captures the master bus to a temp track during playback
// // and never invokes the offline render pipeline.
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
// its offline-render progress dialog (progress bar + waveform view) for the
// duration of the render. The RENDER_SETTINGS bits documented in
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
// from showing this UI for an offline file render triggered via Main_OnCommand.
// This is inherent to REAPER's offline render path. The dialog-free alternative
// is the realtime-record backend (M8), which captures the master bus output to a
// temp track during playback and never invokes the offline render pipeline.
#include "shell/capture/capture.h" #include "shell/capture/capture.h"
@@ -64,72 +48,50 @@ namespace reasampler::capture {
namespace { namespace {
// --- Render command / setting constants ------------------------------------- // The no-dialog render is the built-in action "File: Render project, using the
// // most recent render settings" — command id 42230. Stock main action id, not in
// DAW-ONLY ASSUMPTION (open question, CONTEXT.md §Open questions): the no-dialog // reaper_plugin_functions.h, confirmed against a running REAPER. Renders
// render is triggered by the built-in action "File: Render project, using the // headlessly using whatever RENDER_* settings are currently on the project —
// most recent render settings" — command id 42230. This is a stock REAPER main // why we set them all explicitly first.
// action id, NOT part of reaper_plugin_functions.h, so it CANNOT be verified
// against the SDK header; it must be confirmed in a running REAPER. It renders
// headlessly (no dialog) using whatever RENDER_* settings are currently on the
// project — which is exactly why we set them all explicitly first.
constexpr int kActionRenderUsingMostRecentSettings = 42230; constexpr int kActionRenderUsingMostRecentSettings = 42230;
// RENDER_BOUNDSFLAG value 0 = custom time bounds (we set STARTPOS/ENDPOS // RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042. // for exact, unrounded bounds). SDK header ~3042.
constexpr double kBoundsCustom = 0.0; constexpr double kBoundsCustom = 0.0;
// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail // RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure
// are driven from the pure tailRenderSettingsFor mapping (render_settings.h), // tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below.
// unit-tested outside the DAW. See the tail-driving block in capture() below.
// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping. // RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header ~3050).
// Verified: SDK header line ~3050: "&16=disable all". // Float32 doesn't need dither, but an enabled project dither setting would
// Float-32 output does not need dither, but if the user's project has dither // otherwise apply and break bit-identical repeats. Force off.
// enabled the render would obey it, breaking bit-identical repeats. Force off.
constexpr double kDitherDisableAll = 16.0; constexpr double kDitherDisableAll = 16.0;
// --- WAV render sink configuration ------------------------------------------ // 32-bit IEEE float: lossless, needs no dither, so identical inputs render
// bit-identically and a dry capture nulls exactly against its source. 16/24-bit
// int paths need dither for correctness, which is nondeterministic.
// //
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm): // GetSetProjectInfo_String("RENDER_FORMAT", ...) takes the BASE64-ENCODED sink
// 32-bit IEEE float. Rationale: float is lossless and needs NO dither, so // config, not raw bytes (SDK header ~3114) — raw bytes are silently rejected and
// identical inputs render bit-identically (enables the M10 null test) and a dry // REAPER falls back to its project default format.
// capture nulls exactly against its source. 16/24-bit int paths require dither
// for correctness, which is nondeterministic — unacceptable for a precision tool.
// //
// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...) // Ground truth captured from a live REAPER set to WAV/32-bit float. Decodes to
// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes. // 7 bytes: "evaw" (WAV fourcc, LE) + 0x20 (float bit-depth) + 0x00 0x00 (flags).
// Writing raw bytes causes REAPER to silently reject the value and fall back to
// the project's default render format (typically 16-bit/44.1 kHz). This was the
// confirmed root cause of the M3 offline-capture regression.
//
// GROUND TRUTH: base64 string captured from a live REAPER configured to
// WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00
// = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field)
// + 0x00 0x00 (flags: little-endian, no BWF/loop metadata).
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA=="; constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";
// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding // Int16/Int24 blobs aren't implemented — no live-captured ground truth exists;
// was not captured from a live REAPER and must not be guessed. If M7+ adds // do not guess the encoding. Returns nullptr for unsupported depths.
// them, capture the ground-truth base64 from a running REAPER first.
//
// Returns nullptr for unsupported depths.
const char* wavSinkConfigBase64(WavBitDepth depth) { const char* wavSinkConfigBase64(WavBitDepth depth) {
switch (depth) { switch (depth) {
case WavBitDepth::Float32: return kRenderFormatWavFloat32; case WavBitDepth::Float32: return kRenderFormatWavFloat32;
case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first case WavBitDepth::Int16: return nullptr; // capture ground-truth blob first
case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first case WavBitDepth::Int24: return nullptr; // capture ground-truth blob first
} }
return nullptr; return nullptr;
} }
// --- RENDER_* snapshot / restore -------------------------------------------- // RENDER_* settings are project-GLOBAL; snapshot every value we touch and
// // restore on the way out via RAII so early returns can't leak a half-restored state.
// The RENDER_* settings are project-GLOBAL: clobbering them would destroy the
// user's render configuration. We snapshot every value we are about to change,
// then restore all of them in the reverse order on the way out (non-destructive
// invariant). Modeled as a small RAII guard so early returns cannot leak a
// half-restored state.
struct RenderSettingsSnapshot { struct RenderSettingsSnapshot {
ReaProject* proj = nullptr; ReaProject* proj = nullptr;
@@ -191,8 +153,6 @@ void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
void restoreRenderSettings(const RenderSettingsSnapshot& s) { void restoreRenderSettings(const RenderSettingsSnapshot& s) {
if (!s.captured) return; if (!s.captured) return;
// Restore strings first, then numerics — order is not load-bearing since the
// fields are independent, but we mirror snapshot order for readability.
setProjString(s.proj, "RENDER_FILE", s.renderFile); setProjString(s.proj, "RENDER_FILE", s.renderFile);
setProjString(s.proj, "RENDER_PATTERN", s.renderPattern); setProjString(s.proj, "RENDER_PATTERN", s.renderPattern);
setProjString(s.proj, "RENDER_FORMAT", s.renderFormat); setProjString(s.proj, "RENDER_FORMAT", s.renderFormat);
@@ -221,30 +181,16 @@ struct ScopedRenderSettings {
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete; ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
}; };
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
// empty on any I/O failure (the caller then leaves contentHash empty — the safe,
// confirm-eliciting direction for an unreadable file).
} // namespace } // namespace
// --- Shared backend helpers (Q-W3 riders — see capture.h) --------------------
std::string makeUniqueTag(const std::string& prefix) { std::string makeUniqueTag(const std::string& prefix) {
// Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone // Timestamp + per-session monotonic counter: the timestamp alone has one-second
// had one-second resolution: two captures of the same baseName within the same // resolution, so two captures of the same baseName within a second (batch
// wall-clock second derived the same file stem, so the second render silently // capture) collided on file stem and Sample id. This varies the file NAME, not
// overwrote the first file and minted two Samples with colliding ids — // the audio bytes — bit-identical-repeat is about identical content per request.
// reachable in practice via batch capture. The counter (shared across both // Residual: the counter resets per-process, so a same-second collision across
// backends — this is the one definition both call) makes every tag of a // two REAPER instances (or a mid-session reload) remains theoretically possible;
// session distinct regardless of timing. NOTE: the tag varies the file NAME, // scoped deliberately to the reachable single-process case.
// not the audio bytes — bit-identical-repeat is about identical *content* for
// identical requests; two deliberate captures naturally live in two files.
// RESIDUAL (Q-W3 review follow-up): the counter is per-process, starting over
// at 0 on every REAPER launch/extension reload, so two separate REAPER
// instances (or a reload mid-session) can still mint the same timestamp+counter
// pair in the same wall-clock second — a same-second cross-process collision
// remains theoretically possible. Scoped to per-session deliberately: this fix
// targets the reachable-in-practice single-process batch-capture case above.
static std::atomic<unsigned long long> counter{0}; static std::atomic<unsigned long long> counter{0};
const std::time_t now = std::time(nullptr); const std::time_t now = std::time(nullptr);
return prefix + std::to_string(static_cast<long long>(now)) + "-" + return prefix + std::to_string(static_cast<long long>(now)) + "-" +
@@ -259,26 +205,19 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
s.trackGuids = req.trackGuids; s.trackGuids = req.trackGuids;
s.channelCount = req.channelCount; s.channelCount = req.channelCount;
// Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read // PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0
// from the caller's project handle. PROJECT_SRATE can read 0 on a project that // (honest "unknown") rather than a bogus literal.
// never explicitly pinned a rate — the value stays 0 (the Sample zero-value)
// rather than a bogus literal (the honest "unknown" both backends shared).
s.sampleRate = (req.sampleRate > 0) s.sampleRate = (req.sampleRate > 0)
? req.sampleRate ? req.sampleRate
: static_cast<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false)); : static_cast<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false));
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) s.captureTempo = Master_GetTempo(); // BPM at capture time
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime // Time signature effective at the capture's START time, so a sample captured
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time, // under 3/4 keeps a 3/4 read-out even if the project later switches to 4/4.
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at // `timeSigProj` is the caller's project pin — offline passes nullptr (active
// that project time, so a sample captured under 3/4 keeps a 3/4 read-out even // project); realtime pins the record's own project. tempoOut is ignored —
// if the project later switches to 4/4. `timeSigProj` is the CALLER's project // captureTempo already carries it.
// pin — offline passes nullptr (the active project); realtime pins the record's
// own project (the T2-09 divergence, kept caller-visible as this argument).
// tempoOut is ignored — captureTempo already carries the master tempo. Leaves
// 0/0 (unstamped) if the API is somehow unavailable; the formatter renders a
// blank musical read-out.
{ {
int tsNum = 0, tsDenom = 0; int tsNum = 0, tsDenom = 0;
double tsTempo = 0.0; double tsTempo = 0.0;
@@ -287,14 +226,10 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
s.captureTimeSigDenom = tsDenom; s.captureTimeSigDenom = tsDenom;
} }
// Content hash: WAV-aware FNV-1a over the finished file's fmt+data chunks so // hashWavContent (not raw hashBytes) skips render-varying metadata chunks
// hashReferencedElsewhere can identify copies in other banks and suppress the // (bext timestamp, iXML, LIST/INFO) so identical audio from two renders/records
// last-reference confirm when another bank still holds the same file. Using // collapses to the same hash, letting dedup find copies across banks.
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks // Unreadable file leaves contentHash empty (bank_model treats "" as non-dedup).
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders/records of
// identical audio collapse to the same hash. Best-effort: an unreadable file
// leaves contentHash empty — the safe, confirm-eliciting direction (bank_model
// treats "" as non-participating in dedup).
{ {
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath); const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
if (!fileBytes.empty()) { if (!fileBytes.empty()) {
@@ -308,16 +243,14 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
CaptureResult result; CaptureResult result;
// Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry // SourceMode::Realtime is refused here — that's the realtime backend's job —
// (pure mapping, unit-tested in render_settings). An unsupported mode (only // so the offline path never silently renders the wrong thing.
// SourceMode::Realtime — that is the M8 realtime backend) is refused here so
// the offline path never silently renders the wrong thing.
const RenderSettingsChoice choice = const RenderSettingsChoice choice =
renderSettingsFor(request.sourceMode, request.wetDry); renderSettingsFor(request.sourceMode, request.wetDry);
if (!choice.supported) { if (!choice.supported) {
result.status = CaptureStatus::UnsupportedMode; result.status = CaptureStatus::UnsupportedMode;
result.message = "OfflineRenderBackend does not render this source mode " result.message = "OfflineRenderBackend does not render this source mode "
"(realtime capture is the M8 backend)."; "(realtime capture is the realtime backend).";
return result; return result;
} }
@@ -328,8 +261,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result; return result;
} }
// Current project (idx -1 == the active project tab). Verified: SDK header // idx -1 == the active project tab.
// line ~1264, EnumProjects(int idx, char*, int).
ReaProject* proj = EnumProjects(-1, nullptr, 0); ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (!proj) { if (!proj) {
result.status = CaptureStatus::NoProject; result.status = CaptureStatus::NoProject;
@@ -337,131 +269,81 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result; return result;
} }
// Resolve the project directory from the .rpp file path. // Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path
// out-param is empty for a project that has never been saved — a reliable
// unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not
// the .rpp location, and is never empty even when unsaved (the original bug —
// captures landed in REAPER's default media location instead of by the .rpp).
// //
// Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read // Flow: read .rpp path; if empty, Main_SaveProject(proj, true) prompts
// the project's .rpp filename. Per SDK header line ~1262: // Save-As and blocks until dismissed; re-read; if still empty (cancelled),
// EnumProjects(int idx, char* projfnOutOptional, int sz) // refuse with NoProject and write nothing.
// "idx=-1 for current project, projfn can be NULL if not interested in filename."
// The out-parameter is the full path to the .rpp file, and is EMPTY for a
// project that has never been saved — making it a reliable unsaved sentinel.
//
// WHY NOT GetProjectPathEx: that function returns the project *recording path*
// (SDK header line ~2548: "Get the project recording path."), NOT the .rpp
// location. For an unsaved project it returns REAPER's default media/recording
// directory — never empty — so it cannot detect the unsaved state. Using it
// caused the original bug: the guard never fired, and captures landed in
// REAPER's default media location rather than alongside the .rpp.
//
// WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project,
// GetProjectPathEx returns the recording path (which may be a media subfolder),
// not the .rpp parent directory. We need the .rpp parent so reasampler_bank/
// sits alongside the .rpp and travels with the project.
//
// FLOW:
// 1. Read .rpp path via EnumProjects(-1, buf, bufsz).
// 2. If non-empty (saved) -> derive project dir as parent of the .rpp.
// 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As.
// Re-read. If now non-empty -> proceed. If still empty (user cancelled) ->
// refuse CaptureStatus::NoProject, write nothing.
//
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
// dialog and blocks until the user dismisses it. "true" = forceSaveAsIn.
// Verified SDK header line ~4599:
// void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional)
// The blocking behaviour and dialog appearance can only be confirmed in a
// running REAPER.
auto readRppPath = [&]() -> std::string { auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0'); std::vector<char> buf(4096, '\0');
// EnumProjects(-1, ...) returns the active project and writes the .rpp
// path into buf. We already have the ReaProject* from the earlier call
// (nullptr-checked above), but calling EnumProjects again is the only
// stock, header-documented way to read the .rpp filename.
EnumProjects(-1, buf.data(), static_cast<int>(buf.size())); EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data()); return std::string(buf.data());
}; };
std::string rppPath = readRppPath(); std::string rppPath = readRppPath();
if (rppPath.empty()) { if (rppPath.empty()) {
// Project is unsaved. Prompt the user to choose a save location.
Main_SaveProject(proj, true); Main_SaveProject(proj, true);
// Re-read: non-empty if the user confirmed, still empty if cancelled.
rppPath = readRppPath(); rppPath = readRppPath();
} }
if (rppPath.empty()) { if (rppPath.empty()) {
// User cancelled the save dialog — refuse, write nothing.
result.status = CaptureStatus::NoProject; result.status = CaptureStatus::NoProject;
result.message = "Project must be saved before capture — nothing captured."; result.message = "Project must be saved before capture — nothing captured.";
return result; return result;
} }
// Derive the project directory as the parent folder of the .rpp file. // Project dir = parent of the .rpp; forward-slash-normalized so the rest of
// std::filesystem::path handles both forward- and back-slash paths; .parent_path() // the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
// gives the containing directory. Convert to forward-slash string so the rest
// of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
const std::string projectDir = [&]() -> std::string { const std::string projectDir = [&]() -> std::string {
namespace fs = std::filesystem; namespace fs = std::filesystem;
std::string dir = fs::path(rppPath).parent_path().string(); std::string dir = fs::path(rppPath).parent_path().string();
// normalizeSlashes is in capture_paths (pure); replicate the transform
// inline here to avoid a cross-module dependency for a one-liner.
for (char& c : dir) { if (c == '\\') c = '/'; } for (char& c : dir) { if (c == '\\') c = '/'; }
// Strip a single trailing slash (defensive; parent_path usually omits it).
if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
return dir; return dir;
}(); }();
// Compute the unique tag ONCE so the file stem and Sample.id carry the same // Compute the tag ONCE — calling makeUniqueTag() twice would let the file
// tag. Calling makeUniqueTag() twice would yield different values (the counter // stem and Sample.id diverge (the counter advances per call).
// advances per call — bug: id and filename diverge).
const std::string uniqueTag = makeUniqueTag(""); const std::string uniqueTag = makeUniqueTag("");
const BankPaths paths = const BankPaths paths =
deriveBankPaths(projectDir, request.baseName, uniqueTag); deriveBankPaths(projectDir, request.baseName, uniqueTag);
// Snapshot + auto-restore ALL render settings we are about to touch.
ScopedRenderSettings guard(proj); ScopedRenderSettings guard(proj);
// --- Drive the render settings (exact, deterministic) -------------------
// Custom time bounds so the rendered length equals the requested range with // Custom time bounds so the rendered length equals the requested range with
// NO rounding and NO added silence (unless a tail was explicitly requested). // NO rounding and NO added silence (unless a tail was explicitly requested).
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true); GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true); GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
// Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping // TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact
// (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize // bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end
// (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim. // trim. NORMALIZE is driven here (not the determinism block below) so the
// RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block // Auto surgical value isn't clobbered.
// below) so the Auto surgical value is not clobbered — the snapshot guard restores
// the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
const TailRenderSettings tail = const TailRenderSettings tail =
tailRenderSettingsFor(request.tailMode, request.tailMs); tailRenderSettingsFor(request.tailMode, request.tailMs);
GetSetProjectInfo(proj, "RENDER_TAILFLAG", GetSetProjectInfo(proj, "RENDER_TAILFLAG",
static_cast<double>(tail.tailFlag), true); static_cast<double>(tail.tailFlag), true);
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true); GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
// Source-selection bits for this mode, from the pure render_settings mapping // Source-selection bits for this mode (SDK header ~3041), all wet-only:
// (verified against SDK header ~3041). All M7 actions are wet-only:
// master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file. // master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
GetSetProjectInfo(proj, "RENDER_SETTINGS", GetSetProjectInfo(proj, "RENDER_SETTINGS",
static_cast<double>(choice.settings), true); static_cast<double>(choice.settings), true);
// Resolve the effective sample rate. When the request carries 0 ("follow // request 0 = "follow project"; PROJECT_SRATE is still readable via
// project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the // GetSetProjectInfo even when PROJECT_SRATE_USE is clear.
// actual value — not left as 0 for REAPER to interpret. SDK header line ~3064:
// PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the
// value is still readable via GetSetProjectInfo even when _USE is clear.
const int effectiveSampleRate = (request.sampleRate > 0) const int effectiveSampleRate = (request.sampleRate > 0)
? request.sampleRate ? request.sampleRate
: static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false)); : static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));
// Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE // Only pin RENDER_SRATE when known (>0) — a brand-new project can read 0 for
// can read 0 on a project that has never explicitly pinned a sample rate (e.g. // PROJECT_SRATE, and forcing RENDER_SRATE=0 would be a bogus literal; leave
// brand-new projects before the user has visited the project settings). Forcing // it unset so REAPER follows its own project-rate default.
// RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by
// moving away from blind passthrough. When the rate is unknown, leave
// RENDER_SRATE unset so REAPER follows its own project-rate default — which is
// correct behaviour for that project — rather than pinning a bogus 0.
if (effectiveSampleRate > 0) { if (effectiveSampleRate > 0) {
GetSetProjectInfo(proj, "RENDER_SRATE", GetSetProjectInfo(proj, "RENDER_SRATE",
static_cast<double>(effectiveSampleRate), true); static_cast<double>(effectiveSampleRate), true);
@@ -469,100 +351,67 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_CHANNELS", GetSetProjectInfo(proj, "RENDER_CHANNELS",
static_cast<double>(request.channelCount), true); static_cast<double>(request.channelCount), true);
// Load-bearing principle: do NOT add the rendered file to the project as an // Load-bearing: never add the rendered file to the project as an item.
// item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true); GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
// Determinism: disable dither so identical inputs produce bit-identical files
// and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
// noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true); GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
// RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None / // None/Manual -> disable-all (byte-identical to pre-tail); Auto -> surgical
// Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical // trim-end (only &32768) + -72 dB TRIMEND — a fixed-threshold trailing-silence
// trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence // trim scales/limits/fades nothing, so Auto stays deterministic. TRIMEND is
// trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring // only consulted when the trim bit is set but written unconditionally for clarity.
// (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
// but we write it unconditionally (harmless when clear) so the value is explicit.
GetSetProjectInfo(proj, "RENDER_NORMALIZE", GetSetProjectInfo(proj, "RENDER_NORMALIZE",
static_cast<double>(tail.normalize), true); static_cast<double>(tail.normalize), true);
GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true); GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);
// Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the // RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
// format extension. Use paths.fileStem — capture_paths owns the .wav suffix // format extension. paths.fileStem already owns the .wav suffix knowledge.
// knowledge; re-stripping here would duplicate that coupling.
setProjString(proj, "RENDER_FILE", paths.absoluteDir); setProjString(proj, "RENDER_FILE", paths.absoluteDir);
setProjString(proj, "RENDER_PATTERN", paths.fileStem); setProjString(proj, "RENDER_PATTERN", paths.fileStem);
// Pin the WAV format using the ground-truth base64 blob for the chosen depth. // Int16/Int24 have no captured ground-truth blob — fail explicitly rather
// Int16/Int24 are not implemented (no live-captured blob) — fail explicitly // than silently mis-render at the wrong bit depth.
// rather than silently mis-render at the wrong bit depth.
const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth); const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth);
if (!fmtBase64) { if (!fmtBase64) {
result.status = CaptureStatus::UnsupportedFormat; result.status = CaptureStatus::UnsupportedFormat;
result.message = "Requested bit depth has no verified RENDER_FORMAT blob " result.message = "Requested bit depth has no verified RENDER_FORMAT blob "
"(M3 supports Float32 only; Int16/Int24 are M7+)."; "(Float32 only; Int16/Int24 not yet supported).";
return result; return result;
// guard's dtor restores every RENDER_* setting here.
} }
setProjString(proj, "RENDER_FORMAT", fmtBase64); setProjString(proj, "RENDER_FORMAT", fmtBase64);
// --- Trigger the render -------------------------------------------------
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
// the render synchronously on the current build. REAPER will show its
// offline-render progress window for the duration (see file-top comment —
// the progress UI is not suppressible via stock API).
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0); Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
// --- Verify the output file exists --------------------------------------- // Main_OnCommand returns void, so a failed render is silent — stat the
// Main_OnCommand returns void, so a failed render is silent. Stat the // expected output path to detect it.
// expected output path; if the file does not exist the render failed.
// Note: std::filesystem is used only in this REAPER-facing .cpp — the pure
// libs (capture_paths, bank_model) remain filesystem-free.
const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName; const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName;
if (!std::filesystem::exists(expectedPath)) { if (!std::filesystem::exists(expectedPath)) {
result.status = CaptureStatus::RenderFailed; result.status = CaptureStatus::RenderFailed;
result.message = "Render produced no output file (expected: " + result.message = "Render produced no output file (expected: " +
expectedPath + "). Check the REAPER console for errors."; expectedPath + "). Check the REAPER console for errors.";
return result; return result;
// guard's dtor restores every RENDER_* setting here.
} }
// --- Populate the Sample ------------------------------------------------- // Record the request's own bounds (exact) rather than re-measuring the file.
// We record the request's own bounds (exact) rather than re-measuring the
// file, so the Sample's range is precisely what was asked for.
Sample s; Sample s;
// Use the same uniqueTag that named the file — calling makeUniqueTag() again // Same uniqueTag that named the file — calling makeUniqueTag() again could
// here would risk a different timestamp if a second boundary crosses between // yield a different value and desync Sample.id from the file name.
// the two calls, making Sample.id inconsistent with the file name.
s.id = "cap-" + uniqueTag + "-" + paths.fileName; s.id = "cap-" + uniqueTag + "-" + paths.fileName;
s.displayName = request.baseName; s.displayName = request.baseName;
s.relativePath = paths.relativePath; // project-relative (invariant) s.relativePath = paths.relativePath; // project-relative (invariant)
s.sourceMode = request.sourceMode; s.sourceMode = request.sourceMode;
s.sourceRange.startSeconds = request.startSeconds; s.sourceRange.startSeconds = request.startSeconds;
s.sourceRange.endSeconds = request.endSeconds; s.sourceRange.endSeconds = request.endSeconds;
// DEFERRED (M6/M7): startPpq, endPpq, and lengthBeats are left at 0. // startPpq/endPpq/lengthBeats left at 0 — PPQ mapping is a placement-time
// PPQ mapping via TimeMap2_timeToBeats is a musical-placement concern for the // concern; seconds are the authoritative source for the render and we don't
// insert milestone; the model refuses to re-derive one bound from the other. // re-derive one bound from the other.
// Seconds are the authoritative source for the render. Do NOT add DAW-
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
s.wetDry = request.wetDry; s.wetDry = request.wetDry;
s.lengthSeconds = request.endSeconds - request.startSeconds; s.lengthSeconds = request.endSeconds - request.startSeconds;
s.tier = model::Tier::Scratch; // captures land in scratch by default s.tier = model::Tier::Scratch;
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) —
// 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE
// either, so the render ran at REAPER's default), captureTempo, the capture-
// start time signature (timeSigProj = nullptr => the active project — matching
// the Master_GetTempo read, which is also active-project), the WAV-aware
// contentHash of the rendered file, and createdTimestamp.
stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath); stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath);
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a // rootNote/loop left empty — a master/track/time-selection render isn't a
// master mix / track / time-selection is not a single played note, so no root // single played note, so no root note is derivable; loop points are set
// note is derivable here — we do NOT guess one. Loop points are set later by an // later by an explicit user action.
// explicit user action, not at capture. Leaving them empty is the honest default;
// the instrument (Phase S) treats an absent root note as "not a pitched sample".
result.status = CaptureStatus::Ok; result.status = CaptureStatus::Ok;
result.sample = s; result.sample = s;
@@ -571,7 +420,6 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
std::to_string(request.endSeconds) + "s] -> " + std::to_string(request.endSeconds) + "s] -> " +
paths.relativePath; paths.relativePath;
return result; return result;
// guard's dtor restores every RENDER_* setting here.
} }
} // namespace reasampler::capture } // namespace reasampler::capture
+45 -90
View File
@@ -1,36 +1,18 @@
#pragma once #pragma once
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). // The shared capture seam: CaptureRequest/CaptureResult (types both backends
// speak), OfflineRenderBackend, and the makeUniqueTag/stampCaptureSample helpers.
// Realtime's async begin/tick/abort surface lives in capture_realtime_shell.h.
// //
// This header declares the SHARED capture seam (Q-W6 split of the former fat // REAPER-free on purpose (bank_model only) so callers can depend on the seam
// header — the realtime backend's async begin/tick/abort surface now lives in // without dragging the SDK into every include site; the .cpp is the REAPER TU.
// capture_realtime_shell.h):
// * CaptureRequest / CaptureResult — everything a capture needs and yields,
// source-mode-agnostic; the types BOTH backends speak.
// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class
// (the former ICaptureBackend interface was deleted in
// Q-W3, T4-26 — it had one deriver and zero polymorphic
// call sites; every construction site instantiates the
// concrete type).
// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared
// finished-capture metadata stamp both backends call
// (Q-W3 riders T1-11 / T2-09).
//
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
// REAPER-free lets callers (the capture orchestration TUs) depend on the seam
// without dragging the SDK into every include site.
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/model/bank_model.h" #include "core/model/bank_model.h"
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract #include "core/capture/render_settings.h" // TailMode — the three-state tail contract
// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header // Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these.
// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source
// MediaTrack* to tap and stampCaptureSample can take the project handles its reads
// pin. The pointers are opaque here — never dereferenced in a pure/header context;
// only the REAPER-facing capture TUs touch them.
class MediaTrack; class MediaTrack;
class ReaProject; class ReaProject;
@@ -38,71 +20,55 @@ namespace reasampler::capture {
using model::Sample; using model::Sample;
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default — // 32-bit float is the default; rationale lives in capture.cpp next to the sink-config bytes.
// rationale lives in capture.cpp next to the sink-config bytes.
enum class WavBitDepth { enum class WavBitDepth {
Int16, Int16,
Int24, Int24,
Float32, Float32,
}; };
// One capture, independent of source mode. Populated by the caller (the action // One capture, independent of source mode.
// handler in M3; the action family in M7) and consumed by a backend.
//
// M3 fills only the fields the master-mix/time-selection path needs; the rest
// are declared now so M7/M8 do not reshape the struct (they are the seam).
struct CaptureRequest { struct CaptureRequest {
SourceMode sourceMode = SourceMode::MasterMix; SourceMode sourceMode = SourceMode::MasterMix;
// Sample-accurate render bounds in project seconds. For the M3 spike these // Sample-accurate render bounds in project seconds — NO rounding.
// come straight from the time selection (GetSet_LoopTimeRange) — NO rounding.
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
// 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet). // 1.0 = fully wet, 0.0 = fully dry. Every current capture action sets 1.0;
// The field is kept as the seam for future true-dry work (M10 null test): // true pre-FX dry isn't available via RENDER_SETTINGS (needs FX-bypass-around-render
// true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires // or the realtime pre-FX path) so this stays a seam for that future work.
// FX-bypass-around-render or the M8 realtime pre-FX path, and will be
// designed alongside the M10 null test. Also recorded on the Sample.
double wetDry = 1.0; double wetDry = 1.0;
// Track GUID(s) the capture came from, when the source mode is track-scoped // Track GUID(s) when source mode is track-scoped (SelectedTracks); empty otherwise.
// (SelectedTracks). Empty for master/items/razor. The action layer (M7) // The backend only copies these onto the Sample — it never reads selection itself.
// resolves the selection to canonical GUID strings and passes them here; the
// backend copies them onto the Sample (it does NOT itself read the selection —
// it stays source-agnostic, driven entirely by the request).
std::vector<std::string> trackGuids; std::vector<std::string> trackGuids;
// Render tail (docs/product/capture-tail.md §The three tail states). Default // Render tail (docs/product/capture-tail.md §The three tail states). None = exact
// None: exact bounds, no added silence — the precision invariant, and the only // bounds, no added silence — the only mode valid for null-test/verify captures.
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for // tailMs applies only to Manual (clamped to 8s by the pure mapping); Auto uses
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the // the 8s cap + -72 dB trim internally, None ignores it.
// 8 s cap + -72 dB trim internally, None ignores it.
TailMode tailMode = TailMode::None; TailMode tailMode = TailMode::None;
double tailMs = 0.0; double tailMs = 0.0;
// Output format. 0 sampleRate => follow project rate (deterministic: the // 0 sampleRate => follow project rate.
// project rate is fixed for a given project).
int sampleRate = 0; int sampleRate = 0;
int channelCount = 2; int channelCount = 2;
WavBitDepth bitDepth = WavBitDepth::Float32; WavBitDepth bitDepth = WavBitDepth::Float32;
// Human base name for the file stem; sanitized by capture_paths. The unique // Sanitized by capture_paths. uniqueTag (disambiguator) is supplied by the
// tag (disambiguator) is supplied separately by the backend caller so the // backend caller so the pure naming logic stays testable.
// pure naming logic stays testable.
std::string baseName = "capture"; std::string baseName = "capture";
std::string uniqueTag; // e.g. a timestamp/counter; may be empty std::string uniqueTag;
}; };
// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure // Every failure is an explicit code, never a thrown exception across the REAPER boundary.
// is an explicit code (never a thrown exception across the REAPER boundary) so
// the action handler can log a precise reason.
enum class CaptureStatus { enum class CaptureStatus {
Ok, Ok,
NoProject, // no active project to render / resolve a bank folder NoProject, // no active project to render / resolve a bank folder
EmptyRange, // start >= end: nothing to render EmptyRange, // start >= end: nothing to render
UnsupportedMode, // backend does not implement this source mode (M3 scope) UnsupportedMode, // backend does not implement this source mode
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only) UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
RenderFailed, // the render action ran but produced no output file RenderFailed, // the render action ran but produced no output file
TransportBusy, // realtime backend: transport already playing/recording — refused TransportBusy, // realtime backend: transport already playing/recording — refused
}; };
@@ -113,44 +79,33 @@ struct CaptureResult {
std::string message; // human-readable detail for the console log std::string message; // human-readable detail for the console log
}; };
// Deterministic offline-render backend. Drives the full offline source family — // Deterministic offline-render backend: master mix / time selection / selected
// master mix / time selection, selected tracks, selected items, razor area — all // tracks / selected items / razor area, all wet-only, optional tail. Source
// wet-only (render_settings.h) with optional tail. The source selection + range // selection + range are resolved by the caller and handed in via CaptureRequest —
// are resolved by the caller (the action layer) and handed in via the // the backend drives RENDER_* and never reads the DAW selection itself.
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection // SourceMode::Realtime returns UnsupportedMode. Non-destructive: restores every
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend). // RENDER_* setting it touches on every path. Plain concrete class — see the
// Non-destructive: restores every RENDER_* setting it touches on every path. // no-shared-interface note in capture_realtime_shell.h before adding one back.
// A plain concrete class — the former ICaptureBackend interface was deleted
// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async
// realtime backend deliberately never implemented it (see SEAM CHOICE below).
class OfflineRenderBackend { class OfflineRenderBackend {
public: public:
CaptureResult capture(const CaptureRequest& request); CaptureResult capture(const CaptureRequest& request);
}; };
// --- Shared backend helpers (Q-W3 riders) ------------------------------------
// Mints the filesystem-safe disambiguating tag for one capture's file stem + // Mints the filesystem-safe disambiguating tag for one capture's file stem +
// Sample id: "<prefix><unix-epoch-seconds>-<n>" where <n> is a PER-SESSION // Sample id: "<prefix><unix-epoch-seconds>-<n>", <n> a per-session monotonic
// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision // counter. Wall-clock seconds alone collide when batch capture drives short
// window: two captures of the same baseName within one second derived the same // renders back-to-back, silently overwriting the first file. `prefix` is the
// stem, so the second render silently overwrote the first file (reachable via // backend's family marker ("" offline, "rt-" realtime).
// batch capture driving short renders back-to-back). The counter makes every tag
// of a session distinct regardless of timing. `prefix` is the backend's family
// marker ("" offline, "rt-" realtime).
std::string makeUniqueTag(const std::string& prefix); std::string makeUniqueTag(const std::string& prefix);
// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp // Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
// was copy-pasted per backend and had silently diverged): trackGuids + // (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
// channelCount (echoed from the request), the resolved sampleRate (request rate, // from `rateProj`), captureTempo, the capture-start time signature
// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo // (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime // active project, realtime pins the record's own project), the WAV-aware
// against `timeSigProj` — the offline path passes nullptr = active project, the // contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp.
// realtime path pins the record's own project; the divergence stays caller-visible // Per-backend bits (id, paths, bounds, tier, realtime's length override) stay
// as this argument), the WAV-aware contentHash of the finished file at // with each caller.
// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting
// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds,
// tier, realtime's recorded-length override) stay with each caller.
void stampCaptureSample(Sample& s, const CaptureRequest& req, void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj, ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath); const std::string& absolutePath);
+76 -115
View File
@@ -1,10 +1,9 @@
// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source // capture_batch.cpp — the batch-capture family + re-capture-from-source. See the
// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded // header.
// as a parameter). See the header.
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract). // pointers; here they are extern.
#include "shell/capture/capture_batch.h" #include "shell/capture/capture_batch.h"
@@ -50,28 +49,21 @@
namespace reasampler::capture { namespace reasampler::capture {
// --- M11: batch capture (per selected item / per razor area) ---------------- // One action fires N captures — one sample per selected item (item scope) or per
// razor area (track scope, each area's own range). Each unit routes through
// captureAndIndexOne so every precision invariant holds; nothing lands in the
// arrange (load-bearing principle).
// //
// One action fires N captures — one bank sample per selected item (item scope) or per // Each unit's baseName carries its ordinal ("item-1", "item-2", ...) so two units
// razor area (track scope, each area's own range). Each individual capture honors every // in one batch never share a stem, and makeUniqueTag's per-session monotonic
// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan // counter keeps same-second units across batches from colliding too.
// neutralize, relative paths, channel preservation) and M10 provenance stamping applies
// per capture where its detection rule matches. The load-bearing principle holds: each
// unit writes a file + a bank index entry ONLY; nothing lands in the arrange.
//
// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1",
// "item-2", ...) so two units are never asked to write the same stem within one
// batch, and the shared makeUniqueTag now appends a per-session monotonic counter
// (T1-11 fix) so even same-second units across batches cannot collide.
namespace { namespace {
// RAII snapshot/restore of the project's media-item selection. Batch item capture must // RAII snapshot/restore of the item selection. Batch item capture must transiently
// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is // select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including // selected); the original selection is restored on every exit path — including a
// a mid-batch failure or early return — because selection restoration is part of the // mid-batch failure — as part of the non-destructive invariant.
// non-destructive invariant. Snapshot on construct (the currently-selected item set),
// restore on destruct (deselect everything, then re-select exactly the snapshot).
class ItemSelectionGuard class ItemSelectionGuard
{ {
public: public:
@@ -85,9 +77,8 @@ public:
~ItemSelectionGuard() ~ItemSelectionGuard()
{ {
// Deselect every item in the project, then re-select the snapshot — restoring the // Deselect everything first (not just currently-selected) so any transient
// exact original set regardless of what the batch selected in between. Iterate ALL // selection is cleared, then re-select exactly the snapshot.
// items (not just the currently-selected) so any transient selection is cleared.
const int total = CountMediaItems(nullptr); const int total = CountMediaItems(nullptr);
for (int i = 0; i < total; ++i) for (int i = 0; i < total; ++i)
if (MediaItem* it = GetMediaItem(nullptr, i)) if (MediaItem* it = GetMediaItem(nullptr, i))
@@ -104,9 +95,8 @@ private:
std::vector<MediaItem*> selected_; std::vector<MediaItem*> selected_;
}; };
// Selects exactly `item` (deselect-all then select-one) so the offline render's // Deselect-all then select-one so the offline render's &32 bit captures exactly
// selected-items bit (&32) captures a single item. Used inside the batch loop under the // this item. Called inside ItemSelectionGuard, which restores the original selection.
// ItemSelectionGuard, which restores the user's original selection afterward.
void selectOnlyItem(MediaItem* item) void selectOnlyItem(MediaItem* item)
{ {
const int total = CountMediaItems(nullptr); const int total = CountMediaItems(nullptr);
@@ -115,9 +105,8 @@ void selectOnlyItem(MediaItem* item)
SetMediaItemSelected(it, it == item); SetMediaItemSelected(it, it == item);
} }
// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving // Collects every track's razor areas as (owning track, range) pairs, track order
// track order then area order — the batch analog of resolveRazorRange, which unions them. // then area order. Read-only — never clears the razor selection.
// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser.
std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas() std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
{ {
std::vector<std::pair<MediaTrack*, RazorRange>> areas; std::vector<std::pair<MediaTrack*, RazorRange>> areas;
@@ -135,10 +124,9 @@ std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
return areas; return areas;
} }
// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must // Mirror of ItemSelectionGuard for track selection: batch razor capture transiently
// transiently select exactly the area's owning track per render (track scope's &128 bit // selects the area's owning track per render (&128 renders selected tracks), restoring
// renders whatever TRACKS are selected); the user's original track selection is restored // the original selection on every exit path (non-destructive invariant).
// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard.
class TrackSelectionGuard class TrackSelectionGuard
{ {
public: public:
@@ -170,16 +158,13 @@ private:
} // namespace } // namespace
// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the // One sample per selected item. Snapshots the selection (RAII-restored), transiently
// selection (RAII restore on every path), then for each selected item transiently selects // selects each item in turn, renders its exact range under item-scope FX neutralize,
// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the // and persists once at the end for the whole batch.
// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for
// the whole batch). Reports a mixed-result summary (explicit-action response — allowed).
void RunBatchCaptureItems(ReaSamplerSession& session) void RunBatchCaptureItems(ReaSamplerSession& session)
{ {
// Read the selected items up front (pointers stay valid — batch mutates only selection // Read bounds + owning track now, while the full selection is live and before any
// flags, never adds/removes items). Also capture each item's exact bounds and owning // transient re-selection (batch only mutates selection flags, never adds/removes items).
// track NOW, while the full selection is live, before any transient re-selection.
struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; };
std::vector<ItemUnit> itemUnits; std::vector<ItemUnit> itemUnits;
{ {
@@ -201,8 +186,8 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
return; return;
} }
// Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ // Plan exact ranges -> validated, ordinal-assigned units; zero-length items are
// inverted item ranges (a zero-length item) are dropped here so no stray render runs. // dropped here so no stray render runs.
std::vector<BatchRange> ranges; std::vector<BatchRange> ranges;
ranges.reserve(itemUnits.size()); ranges.reserve(itemUnits.size());
for (const ItemUnit& u : itemUnits) for (const ItemUnit& u : itemUnits)
@@ -212,19 +197,17 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
BatchOutcome outcome; BatchOutcome outcome;
bool anyAdded = false; bool anyAdded = false;
{ {
// Restore the user's ORIGINAL item selection on every exit path (incl. early // selGuard restores the original item selection on every exit path.
// return / mid-batch failure) — non-destructive invariant.
ItemSelectionGuard selGuard; ItemSelectionGuard selGuard;
// The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only // plan and itemUnits are parallel over kept units; skip dropped ranges in lockstep.
// for those whose range survived planning (same drop rule), matching by ordinal.
std::size_t planIdx = 0; std::size_t planIdx = 0;
for (const ItemUnit& u : itemUnits) for (const ItemUnit& u : itemUnits)
{ {
if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep
const CaptureUnit& unit = plan[planIdx++]; const CaptureUnit& unit = plan[planIdx++];
// Transiently select ONLY this item so the item-scope render captures exactly it. // select only this item so the item-scope render captures exactly it.
selectOnlyItem(u.item); selectOnlyItem(u.item);
ResolvedSource src; ResolvedSource src;
@@ -245,9 +228,9 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
} }
} // selGuard restores the original selection here, on every path } // selGuard restores the original selection here, on every path
// Persist ONCE for the whole batch (one ext-state write) — only if something landed. // One ext-state write for the whole batch, only if something landed. The generation
// S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, // bump is monotonic, so one increment past the last-seen value triggers reload in
// so a single increment past the last-seen value is enough to trigger one instance reload. // any listening instance.
if (anyAdded) { if (anyAdded) {
session.bumpBankGeneration(); session.bumpBankGeneration();
session.saveToActiveProject(); session.saveToActiveProject();
@@ -256,12 +239,10 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
} }
// Batch razor capture: one bank sample per razor AREA, track scope over that area's own // One sample per razor area, track scope over that area's own range. Track scope
// range (the area's owning track is the source track). Track scope renders the selected // renders selected tracks via master (&128), so each unit selects only its owning
// TRACKS via master (&128), so each unit transiently selects ONLY its owning track // track under TrackSelectionGuard; the razor selection itself is read-only. Persists
// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original // once at the end.
// track selection on every path. The razor selection itself is read-only and left intact.
// Persists ONCE at the end. Reports a mixed-result summary.
void RunBatchCaptureRazor(ReaSamplerSession& session) void RunBatchCaptureRazor(ReaSamplerSession& session)
{ {
const std::vector<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas(); const std::vector<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas();
@@ -280,7 +261,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
BatchOutcome outcome; BatchOutcome outcome;
bool anyAdded = false; bool anyAdded = false;
{ {
// Restore the user's ORIGINAL track selection on every exit path. // selGuard restores the original track selection on every exit path.
TrackSelectionGuard selGuard; TrackSelectionGuard selGuard;
std::size_t planIdx = 0; std::size_t planIdx = 0;
@@ -290,8 +271,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
const CaptureUnit& unit = plan[planIdx++]; const CaptureUnit& unit = plan[planIdx++];
MediaTrack* tr = a.first; MediaTrack* tr = a.first;
// Transiently select ONLY this track so the track-scope render (&128) captures // select only this track so track-scope render (&128) captures it via master.
// exactly it via master (over the custom time bounds we set per unit).
SetOnlyTrackSelected(tr); SetOnlyTrackSelected(tr);
ResolvedSource src; ResolvedSource src;
@@ -312,7 +292,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
} }
} // selGuard restores the original track selection here, on every path } // selGuard restores the original track selection here, on every path
// S9: one coalesced bump for the whole razor batch (see the item-batch note above). // One coalesced generation bump for the whole batch (see the item-batch note above).
if (anyAdded) { if (anyAdded) {
session.bumpBankGeneration(); session.bumpBankGeneration();
session.saveToActiveProject(); session.saveToActiveProject();
@@ -321,25 +301,15 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
} }
// --- M10: re-capture from source -------------------------------------------- // Regenerates a provenanced sample's file from its recorded source's current state
// and updates the bank Sample in place. Bank-only — never calls InsertMedia; the
// user re-places manually if they want the new version on the timeline.
// Non-destructive to the source (FxBypassGuard snapshot/restore via renderOffline).
// //
// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT // Failure modes are explicit and reported, each a no-op: no provenance, unparseable
// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and // fingerprint, a missing recorded source track, or a failed render. On success, if
// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the // the source FX chain drifted since capture, the user is told — the re-capture still
// load-bearing capture-never-places line, structurally visible: this function has no // reflects the source as it is now (drift is detected, not frozen against).
// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore
// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places
// manually if they want the new version on the timeline.
//
// Failure modes are handled explicitly and reported to the user (a direct response
// to an explicit action is allowed by the console policy):
// * the selected sample has no provenance (not a resample) -> reported, no-op.
// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op.
// * the recorded source track(s) no longer exist -> reported, no-op.
// * the render itself fails to satisfy the recorded request -> reported, no-op.
// On success, if the source FX chain drifted since capture (recorded vs current
// identity differ) the user is told — the re-capture still reflects the source AS IT
// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source).
void RunRecaptureFromSource(ReaSamplerSession& session) void RunRecaptureFromSource(ReaSamplerSession& session)
{ {
const std::vector<std::string> selected = bankPanelSelectedSampleIds(); const std::vector<std::string> selected = bankPanelSelectedSampleIds();
@@ -371,8 +341,8 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
return; return;
} }
// Parse the recorded capture recipe from the fingerprint. A legacy / corrupt // Parse the recorded recipe; legacy/corrupt fingerprints fail gracefully, never
// string fails gracefully — never a partial re-capture. // a partial re-capture.
const std::string recordedParentId = orig->provenance->parentSampleId; const std::string recordedParentId = orig->provenance->parentSampleId;
const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; const std::string recordedFingerprint = orig->provenance->fxChainSnapshot;
const std::optional<model::CaptureRecipe> recipe = const std::optional<model::CaptureRecipe> recipe =
@@ -384,8 +354,7 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
return; return;
} }
// Resolve the recorded source track GUID(s) to live tracks. Any missing track is a // Missing recorded track = hard failure; never silently re-capture a different source.
// hard failure — we will not silently re-capture a different source.
std::vector<MediaTrack*> sourceTracks; std::vector<MediaTrack*> sourceTracks;
for (const std::string& g : recipe->trackGuids) for (const std::string& g : recipe->trackGuids)
{ {
@@ -400,8 +369,7 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
} }
if (sourceTracks.empty()) if (sourceTracks.empty())
{ {
// The recipe recorded no source tracks (e.g. an item-scope capture whose source // e.g. an item-scope capture with no track-scoped source — nothing to resolve.
// tracks were not track-scoped). Without a resolvable source we cannot re-run.
ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this "
"sample; cannot re-capture from source.\n"); "sample; cannot re-capture from source.\n");
return; return;
@@ -411,10 +379,9 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item
: CaptureScope::Track; : CaptureScope::Track;
// Rebuild the capture request verbatim from the recorded recipe — the SAME request, // Rebuild the request verbatim from the recorded recipe, re-run against the
// re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, // source's current state: an unchanged source reproduces a byte-identical file
// channels, bit depth all match the original so an unchanged source produces a // (bit-identical-repeats invariant).
// byte-identical file (bit-identical-repeats invariant, consumed as a feature).
CaptureRequest req; CaptureRequest req;
req.sourceMode = static_cast<SourceMode>(recipe->sourceMode); req.sourceMode = static_cast<SourceMode>(recipe->sourceMode);
req.startSeconds = recipe->startSeconds; req.startSeconds = recipe->startSeconds;
@@ -428,10 +395,9 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
req.trackGuids = recipe->trackGuids; req.trackGuids = recipe->trackGuids;
// Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to // Read the current FX-chain identity BEFORE the render bypasses it, for drift
// compare against the recorded identity for drift reporting. Mirror the same // comparison against the recorded identity (item scope via TakeFX_*, track scope
// scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; // via TrackFX_*).
// track scope reads the track FX chain via TrackFX_*.
std::string currentIdentity; std::string currentIdentity;
if (scope == CaptureScope::Item) { if (scope == CaptureScope::Item) {
const int n = CountSelectedMediaItems(nullptr); const int n = CountSelectedMediaItems(nullptr);
@@ -459,11 +425,10 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
return; return;
} }
// Update the Sample IN PLACE: keep its identity (id) and its provenance thread // Update in place: keep identity (id) + provenance parent, adopt the regenerated
// (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but // file's path/hash/length/rate/timestamp, and rebuild the fingerprint with the
// adopt the regenerated file's path / hash / length / rate / timestamp. The // current FX identity so the next re-capture measures drift from here, not the
// fingerprint is rebuilt from the recipe with the CURRENT FX identity so a // original.
// subsequent re-capture measures drift from this point, not the original.
model::CaptureRecipe refreshed = *recipe; model::CaptureRecipe refreshed = *recipe;
refreshed.fxChainIdentity = currentIdentity; refreshed.fxChainIdentity = currentIdentity;
@@ -476,33 +441,29 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
updated.sampleRate = res.sample.sampleRate; updated.sampleRate = res.sample.sampleRate;
updated.lengthSeconds = res.sample.lengthSeconds; updated.lengthSeconds = res.sample.lengthSeconds;
updated.captureTempo = res.sample.captureTempo; updated.captureTempo = res.sample.captureTempo;
updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp updated.captureTimeSigNum = res.sample.captureTimeSigNum; // refresh meter stamp
updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
updated.trackGuids = res.sample.trackGuids; updated.trackGuids = res.sample.trackGuids;
updated.createdTimestamp = res.sample.createdTimestamp; updated.createdTimestamp = res.sample.createdTimestamp;
// NOTE: levels, clipped, and lengthBeats are carried from the original (via the // levels/clipped/lengthBeats carry from *orig — the offline backend doesn't
// *orig copy above) because the offline backend does not populate them today // populate them; refresh from res.sample here if that ever changes.
// (res.sample leaves them at defaults). If a later milestone populates these
// fields at capture time, refresh them here from res.sample instead.
model::Provenance prov; model::Provenance prov;
prov.parentSampleId = recordedParentId; prov.parentSampleId = recordedParentId;
prov.fxChainSnapshot = model::buildFingerprint(refreshed); prov.fxChainSnapshot = model::buildFingerprint(refreshed);
updated.provenance = prov; updated.provenance = prov;
// Single batched undo point around the in-place bank mutation (mirrors the bank // One batched undo point around the in-place mutation; index-only ext-state,
// action family's R-B pattern). The mutation is index-only ext-state; the render // nothing placed on the timeline.
// wrote a new file but placed nothing on the timeline.
Undo_BeginBlock2(nullptr); Undo_BeginBlock2(nullptr);
const bool changed = session.book().updateSampleInPlace(sampleId, updated); const bool changed = session.book().updateSampleInPlace(sampleId, updated);
if (changed) if (changed)
{ {
// Record the regenerated file in the owned manifest (a new file the tool wrote); // Record the regenerated file in the owned manifest; the superseded file
// the superseded old file becomes an orphan reclaimed by Phase R prune. // becomes an orphan for prune to reclaim.
session.owned().add(updated.relativePath); session.owned().add(updated.relativePath);
// S9: re-capture-in-place regenerates the SAME id's audio — the exact case the // Regenerating the same id's audio is exactly why instances need the generation
// hands-free refresh exists for (an instance referencing this id keeps playing the // bump — they'd otherwise keep playing stale audio until reload. Bumped inside
// OLD audio until it reloads). Bump inside the undo block so undo rolls back the // the undo block so undo rolls back the generation with the rest of the blob.
// generation with the rest of the blob.
session.bumpBankGeneration(); session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
+8 -18
View File
@@ -1,23 +1,13 @@
#pragma once #pragma once
// capture_batch — the batch-capture family + re-capture-from-source (Q-W3 hoist // The batch-capture family + re-capture-from-source: RunBatchCaptureItems (one
// out of main.cpp; the fourth hoist, T4-02 — recapture is planner-driven like // sample per selected item), RunBatchCaptureRazor (one sample per razor area),
// batch and shares the RAII selection-guard machinery, so it belongs here, not // RunRecaptureFromSource (regenerate a provenanced sample from its recorded
// with the single-shot path). Owns: // source's current state, bank-only, in place). Every unit routes through
// * RunBatchCaptureItems — one bank sample per SELECTED item (item scope), the // capture_orchestrator so every precision invariant holds; persist is batched to
// user's item selection snapshot/restored on every path (ItemSelectionGuard); // one ext-state write per action.
// * RunBatchCaptureRazor — one bank sample per razor AREA (track scope over the
// area's own range), the user's track selection snapshot/restored on every
// path (TrackSelectionGuard);
// * RunRecaptureFromSource — regenerate a PROVENANCED bank sample from its
// recorded source's CURRENT state, updating the Sample in place. BANK-ONLY.
// //
// Every unit honors every precision invariant via capture_orchestrator's // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// captureAndIndexOne / renderOffline (exact bounds, non-destructive neutralize, // (main.cpp owns the API pointers).
// relative paths); nothing here ever touches the arrange/timeline (load-bearing
// principle). Persist is batched: ONE ext-state write per action.
//
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
namespace reasampler { namespace reasampler {
class ReaSamplerSession; class ReaSamplerSession;
+4 -8
View File
@@ -1,12 +1,8 @@
// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert // See capture_orchestrator.h. FxBypassGuard lives here as a stack RAII object —
// action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session // it must restore on every exit path of exactly one render call.
// threaded as a parameter). See the header. FxBypassGuard lives here as a STACK
// RAII object (precision-invariant-critical — it must restore on every exit path
// of exactly one render call).
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // the one TU that defines the API pointers; here they are extern.
// pointers; here they are extern (CLAUDE.md §contract).
#include "shell/capture/capture_orchestrator.h" #include "shell/capture/capture_orchestrator.h"
+26 -34
View File
@@ -1,25 +1,17 @@
#pragma once #pragma once
// capture_orchestrator — the single-capture orchestration + the realtime/insert // Single-capture orchestration + the realtime/insert action bodies: renderOffline
// action bodies (Q-W3 hoist out of main.cpp, T4-02). Owns: // (one offline render under the scope's FxBypassGuard, shared by single-shot/
// * renderOffline — ONE offline render under the scope's FxBypassGuard (the // batch/recapture), captureAndIndexOne (render + provenance + bank add +
// stack-RAII out-of-scope FX/fader/pan neutralize, defined in the .cpp — // owned-manifest record, unpersisted), RunCapture/RunCaptureItemAssign,
// precision-invariant-critical, shared by single-shot / batch / recapture); // RunCaptureRealtimeTrack/RunCancelRealtime (in-flight state lives in
// * captureAndIndexOne — render + provenance stamp + bank add + owned-manifest // realtime_lifecycle), and RunInsertSelected — the one deliberate exception to
// record, WITHOUT persisting (single-shot persists right after; batch persists // capture-never-places.
// once at the end);
// * RunCapture / RunCaptureItemAssign — the bindable single-capture actions;
// * RunCaptureRealtimeTrack / RunCancelRealtime — the realtime action bodies
// (the in-flight state itself lives in realtime_lifecycle);
// * RunInsertSelected — the M6 placement action body (the INTENDED, explicit
// placement path — the one deliberate exception to capture-never-places).
// //
// The session is threaded explicitly (no hidden module state): main.cpp's dispatch // The session is threaded explicitly; no capture path here calls InsertMedia
// passes its ReaSamplerSession. The load-bearing principle holds structurally — // or touches the timeline except RunInsertSelected, on purpose, via the insert shell.
// no capture path here calls InsertMedia or touches the arrange/timeline; only
// RunInsertSelected places, on purpose, via the insert shell.
// //
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). // (main.cpp owns the API pointers).
#include <string> #include <string>
@@ -34,17 +26,17 @@ class ReaSamplerSession;
namespace reasampler::capture { namespace reasampler::capture {
// Renders one CaptureRequest through the offline backend under the scope's // Renders one CaptureRequest through the offline backend under the scope's
// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and // FX-bypass guard. Shared by RunCapture and RunRecaptureFromSource so the
// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE // FX-scope neutralize + render recipe lives in one place. Non-destructive;
// place. Non-destructive; touches no timeline item — it writes a file only. // writes a file only.
CaptureResult renderOffline(CaptureScope scope, CaptureResult renderOffline(CaptureScope scope,
const std::vector<MediaTrack*>& sourceTracks, const std::vector<MediaTrack*>& sourceTracks,
const CaptureRequest& req); const CaptureRequest& req);
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, // Renders one capture request, stamps provenance, adds the Sample to the
// and adds the resulting Sample to the ACTIVE bank + records the created file in // active bank + owned-file manifest — without persisting (batch persists once
// the owned-file manifest — WITHOUT persisting. On success, res.sample.id carries // at the end). res.sample.id carries the landed bank-index id (fresh add or
// the LANDED bank-index id (fresh add or hash-dedup collapse target — S8). // hash-dedup collapse target).
CaptureResult captureAndIndexOne(ReaSamplerSession& session, CaptureResult captureAndIndexOne(ReaSamplerSession& session,
CaptureScope scope, CaptureScope scope,
const ResolvedSource& src, const ResolvedSource& src,
@@ -53,21 +45,21 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
double endSeconds); double endSeconds);
// Runs one capture-action-table row: resolve, render + add + record, persist + // Runs one capture-action-table row: resolve, render + add + record, persist +
// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the S8 // mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the
// capture+assign path consumes it; the plain capture actions ignore it. // arrange-ingest capture+assign path consumes it; plain capture actions ignore it.
std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def); std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def);
// S8 arrange ingest: Item-scope capture into the active bank + assignment-request // Item-scope capture into the active bank + assignment-request write, in one
// write, in one undo block. // undo block.
void RunCaptureItemAssign(ReaSamplerSession& session); void RunCaptureItemAssign(ReaSamplerSession& session);
// STARTS the realtime track capture (async, timer-driven — the in-flight state is // Starts the realtime track capture (async, timer-driven — in-flight state is
// realtime_lifecycle's; OnTimer drives it) / cancels the in-flight one. // realtime_lifecycle's) / cancels the in-flight one.
void RunCaptureRealtimeTrack(ReaSamplerSession& session); void RunCaptureRealtimeTrack(ReaSamplerSession& session);
void RunCancelRealtime(ReaSamplerSession& session); void RunCancelRealtime(ReaSamplerSession& session);
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor // Places the bank panel's selected sample(s) at the edit cursor via the
// via the insert shell. `conform` selects the explicit opt-in tempo-match variant. // insert shell. `conform` selects the explicit opt-in tempo-match variant.
void RunInsertSelected(ReaSamplerSession& session, bool conform); void RunInsertSelected(ReaSamplerSession& session, bool conform);
} // namespace reasampler::capture } // namespace reasampler::capture
+45 -71
View File
@@ -1,11 +1,9 @@
// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell // capture_realtime_finalize.cpp — recorded-file discovery, move-into-bank, the
// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM // Auto-tail decay-scan trim, and finished-Sample population. See the header.
// decay-scan trim, and the finished-Sample population. See the header. The async
// record lifecycle lives in capture_realtime_shell.cpp.
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract). // pointers; here they are extern.
#include "shell/capture/capture_realtime_finalize.h" #include "shell/capture/capture_realtime_finalize.h"
@@ -19,7 +17,7 @@
#include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture #include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture
#include "core/capture/render_settings.h" // autoTrimEndRatio #include "core/capture/render_settings.h" // autoTrimEndRatio
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "core/util/file_bytes.h" // shared whole-file loader
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetTrackNumMediaItems #define REAPERAPI_WANT_GetTrackNumMediaItems
@@ -39,26 +37,21 @@ std::string normSlashes(std::string s) {
return s; return s;
} }
// ============================================================================ // Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime): after the
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) // recorded file is moved into the bank (the file we OWN, never the project), scan
// ============================================================================ // the tail (frames after the original range end) backward for the last frame above
// After the recorded file is stable and moved into the bank (the file we OWN — never // -72 dB and truncate there. Rules:
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail // * no tail frame above -72 dB -> trim back to the range end
// region (frames AFTER the original range end) backward for the last frame above // * signal never drops below -72 dB -> keep the full window (cap did its job)
// -72 dB, and truncate the file there. Rules (spec): // * otherwise -> trim one frame past the last audible
// * no frame in the tail window above -72 dB -> trim back to the original range end
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
// * otherwise -> trim one frame past the last audible
// //
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to // Returns the trimmed length in seconds, or negative for "no trim applied". Any
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and // unreadable/unknown/short file skips the trim rather than risk corrupting the
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) // capture — this is a convenience path, not a correctness one.
// rather than risk corrupting the capture — realtime tail is a convenience path.
// //
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit // Assumes the recorded file is a canonical 32-bit float WAV, fully flushed/closed
// float WAV (REAPER project record format — the manual procedure sets it) and is fully // before this runs (tick()'s Finalizing size-stable wait guarantees that on the
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees // normal path; abort()'s best-effort finalize can race it).
// that for the normal path; abort()'s best-effort finalize races it, documented).
double trimAutoTailInPlace(const std::string& path, double trimAutoTailInPlace(const std::string& path,
double rangeStartSeconds, double rangeStartSeconds,
double rangeEndSeconds) { double rangeEndSeconds) {
@@ -73,21 +66,18 @@ double trimAutoTailInPlace(const std::string& path,
const std::size_t totalFrames = layout.frameCount(); const std::size_t totalFrames = layout.frameCount();
if (totalFrames == 0) return kNoTrim; if (totalFrames == 0) return kNoTrim;
// The original range end as a frame index within the file (frame 0 == start). Use // Range end as a frame index (frame 0 == start), using the file's own sample
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow // rate (authoritative — the request rate may be 0 = follow project). Clamped to
// project). Clamp to the file so a rounding overshoot cannot exceed it. // the file so a rounding overshoot cannot exceed it.
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
if (rangeSeconds <= 0.0) return kNoTrim; if (rangeSeconds <= 0.0) return kNoTrim;
std::size_t rangeEndFrame = static_cast<std::size_t>( std::size_t rangeEndFrame = static_cast<std::size_t>(
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5); rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
// Nothing recorded past the range end (the tail window was empty) -> nothing to if (rangeEndFrame >= totalFrames) return kNoTrim; // tail window was empty
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
if (rangeEndFrame >= totalFrames) return kNoTrim;
// Scan ONLY the tail region (frames after the original range end). The trim never // Scan only the tail region the trim never eats into the range body.
// eats into the range body — the scan starts at rangeEndFrame.
const std::size_t tailFrames = totalFrames - rangeEndFrame; const std::size_t tailFrames = totalFrames - rangeEndFrame;
const std::vector<AudioSample> tailPcm = const std::vector<AudioSample> tailPcm =
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
@@ -97,11 +87,10 @@ double trimAutoTailInPlace(const std::string& path,
const std::size_t lastAbove = audio::lastFrameAboveThreshold( const std::size_t lastAbove = audio::lastFrameAboveThreshold(
tailPcm, layout.channelCount, tailFrames, threshold); tailPcm, layout.channelCount, tailFrames, threshold);
// keptFrames: the total frame count the trimmed file retains. // keptFrames: the trimmed file's total frame count. No audible tail frame -> trim
// no audible tail frame -> trim back to the range end (rangeEndFrame frames) // back to rangeEndFrame; an audible frame at idx -> keep through that frame. The
// an audible frame at idx -> keep range body + up to and including that frame // "never drops below threshold" case falls out naturally: lastAbove is the final
// The "signal never falls below threshold" case falls out naturally: lastAbove is // tail frame, so keptFrames == totalFrames.
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
std::size_t keptFrames; std::size_t keptFrames;
if (lastAbove == audio::kNoFrameAboveThreshold) { if (lastAbove == audio::kNoFrameAboveThreshold) {
keptFrames = rangeEndFrame; keptFrames = rangeEndFrame;
@@ -113,21 +102,17 @@ double trimAutoTailInPlace(const std::string& path,
const WavTruncatePlan plan = planWavTruncate(layout, keptFrames); const WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
if (!plan.valid) return kNoTrim; if (!plan.valid) return kNoTrim;
// Patch the RIFF + data size fields in the in-memory buffer so they describe the // Patch RIFF + data size fields to the kept frame count (wav_codec's patch
// kept frame count (wav_codec's patch primitive — the one RIFF owner), then // primitive — the one RIFF owner), then rewrite the file as exactly the first
// rewrite the file as exactly the first newFileByteLength bytes (header + // newFileByteLength bytes. A single truncating write avoids a separate resize
// patched sizes + retained PCM). A single truncating write is the simplest // step and any partial-write window where on-disk sizes and length disagree.
// correct truncate — no separate resize step, no partial-write window where the
// on-disk sizes and length disagree. The result is a valid, playable WAV of the
// kept frames (verified by the wav_codec re-parse test).
patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full // A mid-write failure (full disk, yanked drive) would leave a short file while we
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the // return kNoTrim, overstating the Sample length. Vanishingly unlikely for a
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded // just-recorded local file, and this is a convenience path, so a temp-file+
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic- // atomic-rename isn't warranted; flagged rather than built.
// rename is not warranted here; flagged rather than built.
std::ofstream out(path, std::ios::binary | std::ios::trunc); std::ofstream out(path, std::ios::binary | std::ios::trunc);
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
out.write(reinterpret_cast<const char*>(bytes.data()), out.write(reinterpret_cast<const char*>(bytes.data()),
@@ -190,12 +175,8 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
std::filesystem::remove(recorded, rmEc); // best-effort std::filesystem::remove(recorded, rmEc); // best-effort
} }
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the // Only Auto trims; None recorded exact bounds and Manual is a fixed window
// BANK file we now own (destPath), never the project. Best-effort: an unreadable / // (spec §The realtime path).
// unknown-format / short file skips the trim (keeps the full window) rather than
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
// or < 0 for "no trim applied".
double trimmedLenSeconds = -1.0; double trimmedLenSeconds = -1.0;
if (request.tailMode == TailMode::Auto) { if (request.tailMode == TailMode::Auto) {
trimmedLenSeconds = trimAutoTailInPlace(destPath, trimmedLenSeconds = trimAutoTailInPlace(destPath,
@@ -203,7 +184,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
request.endSeconds); request.endSeconds);
} }
// The pure recorded-capture -> Sample mapping (identity, bounds echo, tier). // Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
RecordedCapture cap; RecordedCapture cap;
cap.relativePath = paths.relativePath; cap.relativePath = paths.relativePath;
cap.uniqueTag = uniqueTag; cap.uniqueTag = uniqueTag;
@@ -218,23 +199,16 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
result.status = CaptureStatus::Ok; result.status = CaptureStatus::Ok;
result.sample = sampleFromRecordedCapture(cap); result.sample = sampleFromRecordedCapture(cap);
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount // Shared finished-capture stamp. timeSigProj = proj: the realtime path pins the
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read // record's own project (offline reads the active project instead) — the
// against the record's OWN project), captureTempo, the capture-start time // divergence is kept caller-visible here.
// signature (timeSigProj = proj: the realtime path PINS the record's own
// project — the divergence from offline's active-project read, kept
// caller-visible here), the WAV-aware contentHash of the (possibly trimmed)
// bank file, and createdTimestamp.
stampCaptureSample(result.sample, request, /*rateProj=*/proj, stampCaptureSample(result.sample, request, /*rateProj=*/proj,
/*timeSigProj=*/proj, destPath); /*timeSigProj=*/proj, destPath);
// The recorded file's true length differs from the request range when a tail was // The recorded length differs from the request range when a tail was recorded,
// recorded, so the Sample length must reflect the FILE, not the range: // so lengthSeconds must reflect the file, not the range: trimmed length if Auto
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. // trimmed, else the full recorded window (recordWindowEnd - start; equals the
// Auto with no trim, or Manual -> the full recorded window (end - start). // exact range when tailMode is None).
// None -> the exact range (unchanged; recordWindowEnd == endSeconds).
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
if (trimmedLenSeconds >= 0.0) { if (trimmedLenSeconds >= 0.0) {
result.sample.lengthSeconds = trimmedLenSeconds; result.sample.lengthSeconds = trimmedLenSeconds;
} else { } else {
+17 -22
View File
@@ -1,15 +1,13 @@
#pragma once #pragma once
// capture_realtime_finalize — the FILE-SIDE half of the realtime-record shell // The file-side half of the realtime-record shell: discovers the file REAPER
// (Q-W3, T4-08 split riding the Q-9 rename): discovering the file REAPER actually // actually recorded, moves it into the bank, runs the Auto-tail decay-scan trim,
// recorded, moving it into the bank, the Auto-tail PCM decay-scan trim, and the // and populates the finished Sample. The async record lifecycle (state
// finished-Sample population. The async record LIFECYCLE (state snapshot/restore, // snapshot/restore, begin/tick/abort) lives in capture_realtime_shell.cpp; this
// begin/tick/abort) lives in capture_realtime_shell.cpp; this half talks to // half talks to wav_codec and the filesystem, not the transport.
// wav_codec and the filesystem, not to the transport.
// //
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). // (main.cpp owns the API pointers). MediaTrack/ReaProject are forward-declared
// MediaTrack / ReaProject are forward-declared (via capture.h) so this header // (via capture.h) so this header stays SDK-lite.
// stays SDK-lite.
#include <string> #include <string>
@@ -18,21 +16,18 @@
namespace reasampler::capture { namespace reasampler::capture {
// Discovers the file REAPER actually recorded onto the temp track: the first media // The first media item's active take's source file on the temp track, forward-
// item's active take's source file, forward-slashed. Empty string if nothing was // slashed; empty if nothing was recorded. Also used by the lifecycle's flush wait
// recorded (no item / take / source). Also used by the lifecycle's flush wait
// (size-stable check) before finalize runs. // (size-stable check) before finalize runs.
std::string recordedFilePath(MediaTrack* temp); std::string recordedFilePath(MediaTrack* temp);
// Builds a CaptureResult for a finalized recording: discover the recorded file, // Discovers the recorded file, moves it into the bank at `paths`, Auto-trims the
// move it into the bank at `paths`, Auto-trim the tail decay in place when the // tail decay in place when requested, and populates the Sample (project reads
// request asks for it, and populate the Sample (pure sampleFromRecordedCapture + // pinned to `proj`, the record's own project). Does NOT restore any snapshotted
// the shared stampCaptureSample — both project reads pinned to `proj`, the // state — the caller restores unconditionally afterward, even on a finalize
// record's OWN project). Returns Ok + Sample on success, or a RenderFailed result. // failure, so finalize and restore stay separate steps. `recordWindowEnd` is the
// Does NOT restore any snapshotted state — the caller restores unconditionally // recorded window end in project seconds (>= request.endSeconds when a tail was
// afterward (finalize + restore are separate steps so a finalize failure still // recorded).
// restores). `recordWindowEnd` is the recorded window end in project seconds
// (>= request.endSeconds when a tail was recorded) — the untrimmed-length source.
CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
const CaptureRequest& request, const CaptureRequest& request,
const BankPaths& paths, const BankPaths& paths,
+123 -237
View File
@@ -1,78 +1,49 @@
// capture_realtime_shell.cpp — REAPER-facing realtime-record backend // REAPER-facing realtime-record backend (RealtimeRecordBackend): the async record
// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore + // lifecycle — state snapshot/restore + begin/tick/abort. The file-side half
// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming // (recorded-file discovery, move-into-bank, Auto-tail trim, Sample population)
// rider: the PURE module owns the capture_realtime stem, this shell takes the // lives in capture_realtime_finalize.cpp.
// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file
// discovery, move-into-bank, Auto-tail trim, Sample population — lives in
// capture_realtime_finalize.cpp (T4-08 split).
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // the one TU that defines the API pointers; here they are extern.
// pointers; here they are extern (CLAUDE.md §contract).
// //
// Captures the requested scope over the requested range by RECORDING in realtime // Captures the requested scope by recording in realtime into a hidden temp
// (transport-driven) into a hidden temp track, then moves the recorded file into // track, then moves the recorded file into the bank as a Sample —
// the bank as a Sample — non-destructively. This increment implements the TRACK // non-destructively. TRACK scope only this increment (the selected track's own
// scope only (records the selected track's own output). Item realtime is deferred // output); item realtime is deferred (UnsupportedMode) rather than half-built.
// (UnsupportedMode) rather than silently half-built.
// //
// ============================================================================ // ASYNC: a realtime record takes (end - start) wall-clock seconds; blocking the
// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right") // main thread for that long freezes REAPER's UI. So it's driven across timer
// ============================================================================ // ticks: begin() validates, snapshots all state to restore, creates the temp
// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a // track, routes the source-track tap, arms, CSurf_OnRecord, and returns
// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES // immediately; tick() (from OnTimer, same tick as session.poll()) reads the
// REAPER's UI for the whole record. That is gone. The record is now driven across // transport and on a terminal verdict stops + finalizes/aborts + restores
// timer ticks: // everything; abort() force-terminates (shutdown/project switch) + restores.
// begin() — validate, snapshot ALL state to restore, create the temp track,
// route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY.
// tick() — (from OnTimer, the same tick as session.poll()) read the transport,
// and on a terminal verdict stop + finalize/abort + RESTORE everything.
// abort() — force-terminate now (shutdown / project switch) + RESTORE everything.
// //
// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope // The snapshot + restore live on RealtimeCaptureState, not a function-scope RAII
// RAII guard because the record spans ticks, no single stack frame outlives it. // guard, because the record spans ticks no single stack frame outlives it.
// restore() is idempotent (a restored_ latch): every terminal path — normal // restore() is idempotent: every terminal path (completion, user stop, error,
// completion, user stop, error, second-capture reject, project switch, unload — // project switch, unload) funnels through the same restore. The pure record-mode
// funnels through the SAME single restore, safe to call once from whichever fires. // bookkeeping, recorded-file->Sample mapping, and completion state machine
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the // (advanceRecordPhase) live in core/capture/capture_realtime (unit-tested
// completion state machine (advanceRecordPhase) all live in the pure // outside the DAW); this TU owns only the REAPER-bound lifecycle recipe.
// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU
// owns only the REAPER-bound lifecycle recipe.
// //
// ============================================================================ // TAP: the hidden temp track receives a send FROM each selected source track
// §TAP — track-output tap (selected track's own output, PRE-parent) // (CreateTrackSend(source, temp)) and records its own output (B_MAINSEND=0, so
// ============================================================================ // it never sums back into the master — no feedback, no monitoring double).
// The recipe: the hidden temp track RECEIVES a send FROM each selected source track // Multiple selected tracks sum in the one temp track, matching how offline
// (CreateTrackSend(source, temp)). The temp track records its OWN output // track scope handles a multi-track selection.
// (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into
// the master — no feedback, no monitoring double). Multiple selected tracks each get
// a send into the one temp track, so their outputs SUM in the temp track — matching
// how offline track scope handles a multi-track selection.
// //
// WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard: // Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0
// A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0 // (post-fader), which taps the source track after its own FX/fader/pan — its
// (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader // own output — but before the parent/folder/master sums it. The tap is
// taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly // chain-independent by construction: there's nothing downstream of the branch
// the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is // point to neutralize. (An earlier spike sent FROM the master, which REAPER
// a branch off the signal at the track's output stage; the parent chain downstream // refuses as a feedback loop and silently recorded nothing — a regular
// of that branch is not in the tapped path AT ALL. So the tap is chain-independent // track->track send has no such loop.)
// BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates
// the live chain, altering the user's monitoring) is deliberately NOT used. This is
// the realtime analogue of offline track scope (item + the track's own FX + its own
// fader/pan; parent/folder/master excluded), reached without touching any live FX.
// //
// This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO // Non-destructive: deleting the temp track on teardown removes every send
// a temp track, which REAPER refuses to carry (master->track is a feedback loop), so // created into it (REAPER cannot leave a send dangling to a deleted
// the temp recorded silence. A regular track->track send has no feedback — it works. // destination), so no source track retains any routing change.
//
// Non-destructive: the temp track is deleted on teardown, which removes every send we
// created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so
// NO source track retains any routing change. We never mutate any existing track's
// persistent state; we only add sends FROM the source tracks that vanish with the
// temp track. The selected source tracks are UNCHANGED after capture.
//
// Item realtime is deferred (UnsupportedMode): item scope would need per-item take
// isolation on top of the tap, which is a separate increment.
#include "shell/capture/capture_realtime_shell.h" #include "shell/capture/capture_realtime_shell.h"
@@ -125,10 +96,9 @@ std::string readRppPath() {
return std::string(buf.data()); return std::string(buf.data());
} }
// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no // -1 if unresolved yet. Used by the flush wait to detect stability (size
// item/take/source, or the file does not exist on disk this tick). Used by the flush // unchanged across a tick) before moving the file — a take REAPER is still
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a // flushing grows tick over tick.
// take REAPER is still flushing on the audio thread grows tick over tick.
std::int64_t recordedFileSize(MediaTrack* temp) { std::int64_t recordedFileSize(MediaTrack* temp) {
const std::string path = recordedFilePath(temp); const std::string path = recordedFilePath(temp);
if (path.empty()) return -1; if (path.empty()) return -1;
@@ -140,42 +110,33 @@ std::int64_t recordedFileSize(MediaTrack* temp) {
} // namespace } // namespace
// ============================================================================ // Holds everything to restore across the many ticks the record spans (temp
// RealtimeCaptureState — the in-flight snapshot + idempotent restore // track + its sends, other tracks' I_RECARM, transport, edit cursor, time
// ============================================================================ // selection), plus the request echo needed to finalize the Sample. restore()
// Holds EVERYTHING to restore across the many ticks the record spans (temp track + // is idempotent (restored_ latch) — the single teardown every terminal path calls.
// its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection),
// plus the request echo needed to finalize the Sample. restore() is idempotent
// (restored_ latch) and is the single teardown every terminal path calls.
class RealtimeCaptureState { class RealtimeCaptureState {
public: public:
// Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so // Transport reads use *Ex(proj_) so a project switch mid-record can't read
// a project switch mid-record cannot read the wrong transport), the request // the wrong transport.
// echo, and the resolved bank paths + tag for finalize.
ReaProject* proj_ = nullptr; ReaProject* proj_ = nullptr;
CaptureRequest request_; CaptureRequest request_;
BankPaths paths_; BankPaths paths_;
std::string uniqueTag_; std::string uniqueTag_;
// The RECORDED window end in project seconds (>= request_.endSeconds). For a tail // Project seconds, >= request_.endSeconds. A tail mode runs the transport
// mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set // past the range end (Auto: +8s cap; Manual: +set length) — this, not
// length), so this — not request_.endSeconds is the end the completion state // request_.endSeconds, is what the completion machine waits for.
// machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
double recordWindowEnd_ = 0.0; double recordWindowEnd_ = 0.0;
// The transient sink. The sends we create (from each selected source track INTO // Sends created into temp_ are removed automatically when temp_ is
// temp_) live on those source tracks pointing AT temp_, and are removed automatically // deleted — no separate send handle to track.
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
// destination. So there is no separate send handle to track here.
MediaTrack* temp_ = nullptr; MediaTrack* temp_ = nullptr;
// The record phase (pure state machine drives the transition). Starts Recording.
RecordPhase phase_ = RecordPhase::Recording; RecordPhase phase_ = RecordPhase::Recording;
// Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not // Steady clock (not the play cursor) so a stuck/looping transport is still
// the play cursor — so a stuck/looping transport is still caught, review §3). // caught. begunAt_ set at begin(); finalizingAt_ set on the Recording->
// begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing // Finalizing edge so the flush wait is bounded from the stop, not begin.
// edge (the transport stop) so the flush wait is bounded from the stop, not begin.
std::chrono::steady_clock::time_point begunAt_{}; std::chrono::steady_clock::time_point begunAt_{};
std::chrono::steady_clock::time_point finalizingAt_{}; std::chrono::steady_clock::time_point finalizingAt_{};
@@ -225,37 +186,26 @@ public:
} }
} }
// The single, idempotent teardown. Called on EVERY terminal path (normal // Idempotent teardown called on every terminal path: stop transport if
// completion, user stop, error, project switch, unload). Safe to call more than // still running, delete temp track (drops its sends + recorded item),
// once — the restored_ latch makes every call after the first a no-op. Order: // restore other tracks' arm, restore time selection + edit cursor.
// 1. stop the transport if anything is still running (we own it), // OnStopButtonEx(proj_) is project-scoped, not the global CSurf_OnStop, so
// 2. delete the temp track (drops its receive-sum sends + the recorded item), // a project switch mid-record (proj_ no longer active) still stops OUR
// 3. restore every other track's arm, // project's transport, never the foreign now-active one.
// 4. restore the time selection + edit cursor.
// Stop the record's OWN project transport if it is still playing/recording. Uses
// the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a
// project switch mid-record — where proj_ is no longer the ACTIVE project — stops
// OUR project's transport, never the foreign now-active one. &1=playing,
// &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op).
void stopOwnTransport() { void stopOwnTransport() {
if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_); if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_);
} }
// Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured // If the captured project was closed mid-record, proj_/temp_ point at freed
// project was CLOSED mid-record, proj_/temp_ point at freed memory; // memory; touching them is a use-after-free. ValidatePtr2 with a null
// touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free. // project validates the ReaProject* itself. Every teardown that
// ValidatePtr2 with a null project validates the ReaProject* itself (the header: // dereferences a captured REAPER object must gate on this first.
// "proj is ignored if pointer is itself a project"). Every teardown that
// dereferences a captured REAPER object MUST gate on this first.
bool captureProjectStillOpen() const { bool captureProjectStillOpen() const {
return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*"); return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*");
} }
// Drop the handle WITHOUT touching any REAPER state — for the closed-project case // For the closed-project case: a closed project already reclaimed its temp
// (review §1). A closed project already reclaimed its temp track, arms, and // track, arms, and transport, so drop the handle without touching REAPER state.
// transport; there is nothing to restore and the pointers are freed. Latch
// restored_ so any later terminal path is a no-op (idempotent), but skip every
// REAPER call restore() would make.
void dropWithoutRestore() { void dropWithoutRestore() {
restored_ = true; restored_ = true;
temp_ = nullptr; temp_ = nullptr;
@@ -266,21 +216,16 @@ public:
if (restored_) return; if (restored_) return;
restored_ = true; restored_ = true;
// 1. Transport: stop OUR project's if still running (usually already stopped
// by the terminal path's explicit stop-before-finalize — a safe no-op then).
stopOwnTransport(); stopOwnTransport();
// 2. Temp track: deleting it drops the source-track sends (REAPER removes every // Deleting the temp track drops the source-track sends (REAPER removes
// send whose destination is deleted — no source track is left mutated) AND the // every send whose destination is deleted) and the recorded item in one move.
// recorded arrange item in one move — nothing stays behind (load-bearing).
if (temp_) { DeleteTrack(temp_); temp_ = nullptr; } if (temp_) { DeleteTrack(temp_); temp_ = nullptr; }
// 3. Other tracks' record-arm.
for (const ArmSnap& s : armSnaps_) for (const ArmSnap& s : armSnaps_)
SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm); SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm);
armSnaps_.clear(); armSnaps_.clear();
// 4. Time selection + edit cursor (no view move, no seek).
GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false); GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false);
SetEditCurPos(curPos_, false, false); SetEditCurPos(curPos_, false, false);
} }
@@ -294,13 +239,6 @@ private:
bool finalized_ = false; bool finalized_ = false;
}; };
// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the
// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in
// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle.
// ============================================================================
// begin — start the record, snapshot, return immediately (no UI block)
// ============================================================================
void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept { void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept {
delete p; // full type is visible here — keeps capture.h REAPER-free delete p; // full type is visible here — keeps capture.h REAPER-free
} }
@@ -309,8 +247,8 @@ RealtimeCaptureHandle
RealtimeRecordBackend::begin(const CaptureRequest& request, RealtimeRecordBackend::begin(const CaptureRequest& request,
const std::vector<MediaTrack*>& sourceTracks, const std::vector<MediaTrack*>& sourceTracks,
CaptureResult& outFailure) { CaptureResult& outFailure) {
// Only the track scope is implemented this increment (see §TAP). Item realtime // Item realtime is deferred — needs per-item take isolation on top of the
// is deferred — it needs per-item take isolation on top of the track-output tap. // track-output tap.
if (request.sourceMode != SourceMode::SelectedTracks) { if (request.sourceMode != SourceMode::SelectedTracks) {
outFailure.status = CaptureStatus::UnsupportedMode; outFailure.status = CaptureStatus::UnsupportedMode;
outFailure.message = "RealtimeRecordBackend implements TRACK scope only this " outFailure.message = "RealtimeRecordBackend implements TRACK scope only this "
@@ -354,7 +292,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved. // .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
std::string rppPath = readRppPath(); std::string rppPath = readRppPath();
if (rppPath.empty()) { if (rppPath.empty()) {
Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify) Main_SaveProject(proj, true);
rppPath = readRppPath(); rppPath = readRppPath();
} }
if (rppPath.empty()) { if (rppPath.empty()) {
@@ -365,68 +303,51 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
const std::string projectDir = const std::string projectDir =
normSlashes(std::filesystem::path(rppPath).parent_path().string()); normSlashes(std::filesystem::path(rppPath).parent_path().string());
// --- Build the in-flight state (owns the snapshot + teardown) ---------------
RealtimeCaptureHandle st(new RealtimeCaptureState()); RealtimeCaptureHandle st(new RealtimeCaptureState());
st->proj_ = proj; st->proj_ = proj;
st->request_ = request; st->request_ = request;
st->uniqueTag_ = makeUniqueTag("rt-"); // shared mint (T1-11 monotonic counter) st->uniqueTag_ = makeUniqueTag("rt-");
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_); st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
// The recorded window end: extended past the range end for a tail mode (Auto/Manual), // Extended past the range end for a tail mode (Auto/Manual), exact for
// exact for None. This — not request.endSeconds — is what the completion machine // None; the extra window is trimmed later (Auto) or kept (Manual).
// waits for; the extra window past the range end is trimmed later (Auto) or kept
// (Manual). Pure mapping (render_settings), shared caps with the offline tail.
st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode, st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
request.endSeconds, request.endSeconds,
request.tailMs); request.tailMs);
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT // Deliberately NOT wrapped in an undo block — this backend fully restores
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view // its own state across every terminal path, so an undo point would surface
// shells is intentional. This backend fully restores its own state across every // an internal, fully-reversed scaffold for no user-meaningful action.
// terminal path (the restore() latch); an undo point would surface an internal, // Disarm every other track BEFORE the temp track exists so it's never in
// fully-reversed scaffold in the user's undo history for no user-meaningful action. // the arm snapshot.
// Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp
// track exists (so it is never in the arm snapshot and keeps the arm we set).
st->snapshotAndDisarmOthers(); st->snapshotAndDisarmOthers();
// Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from // Hidden temp track: no default FX/envelopes, hidden from both panels,
// both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring // B_MAINSEND=0 so it doesn't sum back into the master (would otherwise
// invariant — it would otherwise double the tapped tracks in the user's monitoring). // double the tapped tracks in the user's monitoring).
const int idx = CountTracks(proj); const int idx = CountTracks(proj);
InsertTrackAtIndex(idx, false); InsertTrackAtIndex(idx, false);
st->temp_ = GetTrack(proj, idx); st->temp_ = GetTrack(proj, idx);
if (!st->temp_) { if (!st->temp_) {
outFailure.status = CaptureStatus::RenderFailed; outFailure.status = CaptureStatus::RenderFailed;
outFailure.message = "Could not create the hidden temp record track."; outFailure.message = "Could not create the hidden temp record track.";
st->restore(); // undo the disarm + cursor/time-sel snapshot st->restore();
return nullptr; return nullptr;
} }
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0);
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0);
SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0);
// Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp // A send FROM each selected source track INTO the temp track; the temp
// track (CreateTrackSend(source, temp)). The temp records its OWN output, so the // records its own output, so sends sum in it — matching offline track
// sends' outputs SUM in it — multiple selected tracks are captured together (same as // scope's multi-track handling. Sends default to post-fader/full-stereo,
// offline track scope). See §TAP for why this faithfully captures each track's own // left at defaults deliberately — that IS the track-scope tap point.
// output and needs no FxBypassGuard.
//
// Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default,
// (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and
// fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at
// defaults deliberately — that IS the track-scope tap point.
//
// DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record
// reproduces the track's own output sample-for-sample (latency comp, pan law,
// mono/stereo folding) is the crux to verify live.
int sendsMade = 0; int sendsMade = 0;
for (MediaTrack* src : sourceTracks) { for (MediaTrack* src : sourceTracks) {
if (!src || src == st->temp_) continue; if (!src || src == st->temp_) continue;
if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade; if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade;
} }
if (sendsMade == 0) { if (sendsMade == 0) {
// Every send failed (should not happen for valid selected tracks). Refuse
// rather than record a guaranteed-silent file.
outFailure.status = CaptureStatus::RenderFailed; outFailure.status = CaptureStatus::RenderFailed;
outFailure.message = "Could not route any selected track into the record tap — " outFailure.message = "Could not route any selected track into the record tap — "
"nothing to capture."; "nothing to capture.";
@@ -434,10 +355,8 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
return nullptr; return nullptr;
} }
// Record-mode values from the pure planner. The temp track records its OWN output; // The temp track has no FX and unity fader, so its post-fader output
// it has no FX and unity fader, so its post-fader output equals the summed sends. // equals the summed sends; track scope is fully wet -> PostFader.
// Track scope is fully wet -> PostFader. (The actual track-scope tap point is the
// source sends' default post-fader mode; the temp's recmode only records the sum.)
const OutputTap tap = outputTapForWetDry(request.wetDry); const OutputTap tap = outputTapForWetDry(request.wetDry);
const RecordModePlan rec = recordModePlanFor(request.channelCount, tap); const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode)); SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
@@ -446,54 +365,41 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
// Record range: time selection over [start, recordWindowEnd], play cursor at start. // recordWindowEnd extends past the range end for a tail mode so the
// recordWindowEnd extends past the request's range end for a tail mode so the // transport captures the decay; cursor + time selection are restored by restore().
// transport captures the decaying tail; it equals the range end for None (exact
// bounds). Both cursor + time selection were snapshotted and are restored by
// restore().
double rs = request.startSeconds, re = st->recordWindowEnd_; double rs = request.startSeconds, re = st->recordWindowEnd_;
GetSet_LoopTimeRange(true, false, &rs, &re, false); GetSet_LoopTimeRange(true, false, &rs, &re, false);
SetEditCurPos(request.startSeconds, false, false); SetEditCurPos(request.startSeconds, false, false);
// Start the transport and RETURN. tick() drives the rest across timer ticks. // tick() detects completion via the play cursor reaching the range end
// // (the pure state machine), independent of REAPER's auto-punch settings.
// DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact
// range/auto-punch/stop behavior depends on the user's transport settings — not
// header-guaranteed. tick() detects completion via the play cursor reaching the
// range end (the pure state machine), independent of REAPER's auto-punch.
CSurf_OnRecord(); CSurf_OnRecord();
// Anchor the wall-clock safety ceiling from here (steady clock — independent of the // Steady clock, independent of the play cursor, so a transport that starts
// play cursor, so a transport that starts but never advances is still bounded). // but never advances is still bounded.
st->markElapsedStart(); st->markElapsedStart();
return st; return st;
} }
// ============================================================================
// tick — advance the in-flight record; on terminal, finalize/abort + restore
// ============================================================================
RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
RealtimeTickResult out; RealtimeTickResult out;
// If a prior terminal path already tore this down (e.g. abort() then a stray // A prior terminal path (e.g. abort()) already tore this down — spent.
// tick), do nothing — the state is spent.
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
const RecordPhase prevPhase = state.phase_; const RecordPhase prevPhase = state.phase_;
// Read the transport bound to the record's OWN project (a project switch cannot // *Ex(state.proj_) so a project switch can't point these reads at the
// point these reads at the wrong transport). &4 = recording. Gather everything the // wrong transport.
// pure machine needs (transport + wall-clock ceilings + file-flush readiness).
RecordTickInputs inputs; RecordTickInputs inputs;
inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0; inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0;
inputs.transport.playPosition = GetPlayPositionEx(state.proj_); inputs.transport.playPosition = GetPlayPositionEx(state.proj_);
inputs.elapsedSeconds = state.elapsedSeconds(); inputs.elapsedSeconds = state.elapsedSeconds();
// Deferred-finalize flush check (review §2), only meaningful once stopped. The // File is ready when its size is positive and unchanged from the previous
// recorded file is READY when its size is a valid positive value AND unchanged // tick — REAPER finished flushing the take. Comparing across a tick avoids
// from the previous tick — REAPER finished flushing/closing the take on the audio // moving a file mid-write.
// thread. Comparing across a tick avoids moving a file mid-write (truncated take).
if (prevPhase == RecordPhase::Finalizing) { if (prevPhase == RecordPhase::Finalizing) {
state.markFinalizingStartOnce(); state.markFinalizingStartOnce();
inputs.finalizingSeconds = state.finalizingSeconds(); inputs.finalizingSeconds = state.finalizingSeconds();
@@ -502,34 +408,29 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
state.lastFileSize_ = sz; state.lastFileSize_ = sz;
} }
// Wait for the transport to reach the RECORDED window end (extended past the // Waits for the transport to reach the recorded window end (extended for a
// range end for a tail mode), not the request's range end — the extra tail window // tail mode), not the request's range end — the extra tail window is part
// is part of the record. The record safety ceiling scales with it (window - start // of the record.
// + margin) inside the pure machine.
state.phase_ = advanceRecordPhase(state.phase_, inputs, state.phase_ = advanceRecordPhase(state.phase_, inputs,
state.request_.startSeconds, state.request_.startSeconds,
state.recordWindowEnd_); state.recordWindowEnd_);
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER // On Recording -> Finalizing, stop OUR project's transport once so REAPER
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_)) // begins flushing the take; project-scoped so a project switch can't stop
// — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a // the wrong (foreign active) project.
// foreign one during a project switch), not the record's own. The flush wait then
// proceeds across subsequent ticks before the file is moved.
if (prevPhase == RecordPhase::Recording && if (prevPhase == RecordPhase::Recording &&
isStopRequested(state.phase_)) { isStopRequested(state.phase_)) {
state.stopOwnTransport(); state.stopOwnTransport();
state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop state.markFinalizingStartOnce();
} }
if (!isTerminalPhase(state.phase_)) { if (!isTerminalPhase(state.phase_)) {
out.status = RealtimeTickStatus::InProgress; out.status = RealtimeTickStatus::InProgress;
return out; // keep the OnTimer tick fast — recording or flushing return out;
} }
// Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done, // Done: file flushed + stable, finalize moves it into the bank. Failed:
// finalize moves the now-stable file into the bank + builds the Sample. On Failed // flush ceiling tripped, nothing usable.
// (the flush timeout) there is nothing usable — report RenderFailed. Then restore
// ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
CaptureResult res; CaptureResult res;
if (state.phase_ == RecordPhase::Done) { if (state.phase_ == RecordPhase::Done) {
res = finalizeRecording(state.proj_, state.temp_, state.request_, res = finalizeRecording(state.proj_, state.temp_, state.request_,
@@ -550,23 +451,15 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
return out; return out;
} }
// ============================================================================
// abort — force-terminate now (shutdown / project switch) + restore
// ============================================================================
RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
RealtimeTickResult out; RealtimeTickResult out;
// Already torn down (idempotent): report Failed and leave it.
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
// CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ / // If the captured project was closed mid-record, proj_/temp_ point at
// temp_ point at freed memory. The closed project already reclaimed its // freed memory — drop the handle without touching REAPER state. This is
// temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER // the one terminal path that can run against a possibly-closed project
// state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those // (tick() only runs while proj_ is still the active project).
// freed pointers is the use-after-free bug this guard exists to prevent. This is
// the ONE terminal path that can run against a possibly-closed project (tick() only
// runs while proj_ is the active — hence still-open — project); guarding here covers
// both the project-switch and unload callers.
if (!state.captureProjectStillOpen()) { if (!state.captureProjectStillOpen()) {
state.dropWithoutRestore(); state.dropWithoutRestore();
out.result.status = CaptureStatus::RenderFailed; out.result.status = CaptureStatus::RenderFailed;
@@ -576,25 +469,18 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
return out; return out;
} }
// The project is still open (a tab-switch, or a clean unload with the project // Project still open: stop the transport, then try to finalize whatever
// present): stop the transport, then TRY to finalize whatever was captured so a // was captured so a near-complete record keeps its audio; if nothing
// near-complete record still keeps the audio; if nothing was recorded (or the file // usable was recorded, finalize returns RenderFailed and we abort clean.
// has not flushed yet), finalize returns RenderFailed and we abort clean. // abort() is the force-terminate path — unlike tick() it can't span ticks
// Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no // to wait for the flush, so it still races REAPER's audio-thread take close.
// longer active, so the global CSurf_OnStop would stop the wrong (foreign) project.
//
// NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload /
// switch); it cannot span ticks to wait for the flush the way tick() does, so its
// finalize still races REAPER's audio-thread take close. That is inherent to a
// best-effort terminal grab and is acceptable — the normal completion path (tick)
// is the one that must be flush-safe.
state.stopOwnTransport(); state.stopOwnTransport();
CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_, CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_,
state.paths_, state.uniqueTag_, state.paths_, state.uniqueTag_,
state.recordWindowEnd_); state.recordWindowEnd_);
state.markFinalized(); state.markFinalized();
state.restore(); // the non-destructive gate — always runs state.restore();
out.result = res; out.result = res;
out.status = (res.status == CaptureStatus::Ok) out.status = (res.status == CaptureStatus::Ok)
+52 -72
View File
@@ -1,31 +1,21 @@
#pragma once #pragma once
// capture_realtime_shell — the ASYNC realtime-record seam (Q-W6 split of the former // The ASYNC realtime-record seam: begin/tick/abort. capture.h keeps the shared
// fat capture.h: this header owns the realtime backend's begin/tick/abort surface; // CaptureRequest/CaptureResult types, the offline backend, and shared helpers.
// capture.h keeps the shared CaptureRequest/CaptureResult types, the offline // Implemented by capture_realtime_shell.cpp; driven by exactly one caller
// backend, and the shared backend helpers). Implemented by // (realtime_lifecycle).
// capture_realtime_shell.cpp; driven by exactly one caller (realtime_lifecycle).
// //
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport // CSurf_OnRecord starts the transport on REAPER's audio thread and returns
// on REAPER's audio thread and returns immediately — it does NOT block until the // immediately — it does not block until the range completes. Blocking the main
// range completes, which takes (end - start) wall-clock seconds. Blocking the main // thread would freeze REAPER's UI, so the backend is driven across timer ticks
// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN // instead: begin() starts and returns at once; tick() (called from the same
// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called // OnTimer that runs session.poll()) advances the in-flight record.
// from the same OnTimer that runs session.poll()) advances the in-flight record and
// reports when it is done.
// //
// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The // The two backends deliberately share NO interface — do not reintroduce one.
// lifecycles are genuinely different (offline is headless + immediate — one // Offline is headless + immediate (one synchronous capture() call); realtime is
// synchronous capture() call returns a finished Sample; realtime is // transport-driven + async. A shared interface would make offline fake a
// transport-driven + async — begin/tick/abort across timer ticks), so a shared // lifecycle it doesn't have (tick() always Done on first call).
// interface would make offline fake a lifecycle it does not have (its tick()
// would always be Done on the first call — dead code / an LSP smell). Offline
// stays synchronous; the realtime backend owns this small bespoke async seam.
// This is the split-sync/async fork, chosen over a unified async interface for
// that reason. (The old synchronous ICaptureBackend interface over
// OfflineRenderBackend was deleted in Q-W3 — T4-26: one deriver, zero polymorphic
// call sites.)
// //
// REAPER-free like capture.h: MediaTrack is forward-declared there and never // REAPER-free like capture.h: MediaTrack is forward-declared there, never
// dereferenced here; the REAPER-facing TU is capture_realtime_shell.cpp. // dereferenced here; the REAPER-facing TU is capture_realtime_shell.cpp.
#include <memory> #include <memory>
@@ -47,74 +37,64 @@ struct RealtimeTickResult {
CaptureResult result; // meaningful only when status == Done or Failed CaptureResult result; // meaningful only when status == Done or Failed
}; };
// The opaque in-flight capture state. Owns the snapshot of everything to restore // The opaque in-flight capture state: the snapshot of everything to restore
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, // (temp track + its sends from the source tracks, other tracks' I_RECARM,
// transport, edit cursor, time selection) and the record's own project handle. // transport, edit cursor, time selection) and the record's own project handle.
// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is // Defined in capture_realtime_shell.cpp; forward-declared here to stay REAPER-free.
// dereferenced here) by holding it behind a forward-declared type + unique_ptr.
// //
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope // restore()/teardown is idempotent and lives ON THIS OBJECT, not a function-scope
// RAII guard) because the record spans ticks — no single stack frame outlives it. // RAII guard, because the record spans ticks — no single stack frame outlives it.
// Every terminal path (normal completion, user stop, error, project switch, unload) // Every terminal path (completion, user stop, error, project switch, unload)
// funnels through the same single restore, safe to call once from whichever fires. // funnels through the same restore, safe to call once from whichever fires.
class RealtimeCaptureState; class RealtimeCaptureState;
// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the // Out-of-line deleter so callers can own a unique_ptr to the opaque
// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the // RealtimeCaptureState without its full (REAPER-typed) definition.
// delete is compiled in capture_realtime_shell.cpp where the type is complete,
// keeping this header REAPER-free (load-bearing split).
struct RealtimeCaptureStateDeleter { struct RealtimeCaptureStateDeleter {
void operator()(RealtimeCaptureState* p) const noexcept; void operator()(RealtimeCaptureState* p) const noexcept;
}; };
using RealtimeCaptureHandle = using RealtimeCaptureHandle =
std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>; std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
// Realtime-record backend — captures by RECORDING in realtime (transport-driven) // Captures by recording in realtime into a hidden temp track, then moves the
// into a hidden temp track, then moves the recorded file into the bank as a Sample. // recorded file into the bank as a Sample. For sources offline render can't do
// For sources offline render cannot do (hardware, performed FX) and as the true // (hardware, performed FX) and as the true pre-FX-dry path (I_RECMODE_FLAGS
// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline // &3==1 — the only pre-FX tap in the SDK). Dialog-free: never invokes the
// render has none). Dialog-free: never invokes the offline-render progress window. // offline-render progress window.
// //
// Non-bit-identical by nature (it is realtime); offline stays the deterministic // Non-bit-identical by nature; offline stays the deterministic default.
// default. Non-destructive across EVERY terminal path — the review gate — which is // Non-destructive across every terminal path is harder here than offline
// harder here than offline because the record spans ticks: the snapshot + restore // because the record spans ticks: snapshot + restore live on
// live on RealtimeCaptureState, not a function-scope RAII destructor. // RealtimeCaptureState, not a function-scope RAII destructor.
// //
// SCOPE (this increment): TRACK scope only — records the selected track's OWN // TRACK scope only (this increment): records the selected track's own output
// output (item + that track's own FX + its own fader/pan, PRE-parent), matching // (item + track's own FX/fader/pan, pre-parent), matching offline's track
// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's // scope. Needs no FxBypassGuard a send tapping a track's output is naturally
// output is naturally PRE-parent (the parent has not summed it yet), so the tap is // pre-parent, so the tap is chain-independent by construction. Item realtime
// chain-independent by construction. Item realtime is deferred (UnsupportedMode). // is deferred (UnsupportedMode).
class RealtimeRecordBackend { class RealtimeRecordBackend {
public: public:
// Starts a realtime record: validates the request (track scope, non-empty range, // Validates the request (track scope, non-empty range, >=1 source track,
// at least one source track, active + saved project, transport idle), snapshots // active+saved project, transport idle), snapshots state, creates the hidden
// all state to restore, creates the hidden temp track, routes a send FROM each // temp track, routes a send from each source track into it, arms, and
// source track INTO the temp track, arms, and CSurf_OnRecord — then returns // CSurf_OnRecord — then returns immediately. `sourceTracks` are resolved by
// IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to // the caller; CaptureRequest itself stays REAPER-free. On success the
// tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, // returned unique_ptr owns the in-flight state; on failure returns nullptr
// carrying only the provenance GUIDs). On success the returned unique_ptr owns the // with `outFailure` filled (nothing left mutated).
// in-flight state; drive it with tick(). On a validation/setup failure returns
// nullptr and fills `outFailure` with the CaptureStatus + message (nothing was
// left mutated — begin() restores on its own failure paths).
RealtimeCaptureHandle begin(const CaptureRequest& request, RealtimeCaptureHandle begin(const CaptureRequest& request,
const std::vector<MediaTrack*>& sourceTracks, const std::vector<MediaTrack*>& sourceTracks,
CaptureResult& outFailure); CaptureResult& outFailure);
// Advances the in-flight record one tick. Reads the transport (bound to the // Reads the transport (bound to the record's OWN project handle so a project
// record's OWN project handle so a project switch cannot confuse it), and on a // switch can't confuse it); on a terminal verdict stops the transport,
// terminal verdict stops the transport, finalizes the recorded file into the // finalizes the recorded file (Done) or reports the failure (Failed), then
// bank Sample (Done) or reports the failure (Failed), then restores ALL // restores all snapshotted state. After Done/Failed the state is spent.
// snapshotted state. Returns InProgress while the record is still running.
// After Done/Failed the state is spent — the caller drops the unique_ptr.
RealtimeTickResult tick(RealtimeCaptureState& state); RealtimeTickResult tick(RealtimeCaptureState& state);
// Force-terminate an in-flight record NOW without waiting for the range end: // Force-terminate now without waiting for the range end: stops transport,
// stops the transport, finalizes whatever was captured (best effort) or abandons // finalizes best-effort or abandons, restores all snapshotted state. For
// it, and restores ALL snapshotted state. For the shutdown / project-switch // shutdown/project-switch paths where the record must not leak a temp
// paths (extension unload, a new project became active) where the record must // track/armed track/altered transport. Idempotent.
// not leak a temp track / armed track / altered transport into the user's
// project. Idempotent — safe even if a prior tick already tore the state down.
RealtimeTickResult abort(RealtimeCaptureState& state); RealtimeTickResult abort(RealtimeCaptureState& state);
}; };
+46 -65
View File
@@ -1,34 +1,27 @@
// insert.cpp — REAPER-facing placement shell (M6). See insert.h. // insert.cpp — REAPER-facing placement shell. See insert.h.
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
// extern (CLAUDE.md §contract). // extern.
// //
// THIS IS THE INTENDED PLACEMENT PATH. Unlike capture / bank_panel (which never // Unlike capture / bank_panel (which never touch the arrange), insert deliberately
// touch the arrange), insert deliberately adds items to the arrange — that is its // adds items to the arrange — that is its whole job. Runs only from its own action.
// whole job (CONTEXT.md §load-bearing principle). It runs ONLY from its own action.
// //
// FLAGGED RUNTIME ASSUMPTIONS (semantics the header does not fully specify — must // Runtime assumptions the SDK header doesn't fully spell out (flagged, not yet
// be DAW-verified by Daniel post-merge; see the handoff): // DAW-verified):
// A. InsertMedia base mode 0 ("add to current track") targets the track that is // A. InsertMedia mode 0 ("add to current track") is assumed to target the sole
// currently the ONLY selected track. The header names the base target but does // selected track — the header doesn't spell out how "current" resolves, so we
// not spell out how "current track" resolves at runtime. We force exactly one // force exactly one selection via SetOnlyTrackSelected before each call. If
// selected track via SetOnlyTrackSelected before each InsertMedia call, which // REAPER means last-focused rather than last-selected, this needs revisiting.
// is the most defensible interpretation; if REAPER uses a different notion of // B. Mode 0 is assumed to insert at the edit cursor (REAPER's documented
// "current" (e.g. last-focused, not last-selected), DAW-verify and adjust. // convention for base modes 0/1; the header has no explicit "at cursor" bit).
// B. InsertMedia mode 0 inserts AT THE EDIT CURSOR. Placement at the edit cursor // C. InsertMedia may advance the cursor to the end of the inserted media; we
// is REAPER's documented convention for base modes 0/1 (the header does not // reset to the snapshot position before each track's insert, so this doesn't
// spell out an explicit "at edit cursor" bit). Flagged for DAW-verification. // matter either way.
// C. InsertMedia ADVANCES the edit cursor to the end of the inserted media. We // D. SetEditCurPos(time, false, false) — moveview=false, seekplay=false — is
// reset the cursor to the snapshot position before EACH track's insert, so // assumed to move the cursor without scrolling the view or the transport.
// assumption C's truth or falsity is irrelevant: we own the cursor reset. // E. SetOnlyTrackSelected deselects all tracks and selects exactly one (per its
// D. SetEditCurPos(time, false, false) moves the cursor without scrolling the view // header doc-comment — the strongest confirmation we have here).
// and without seeking the transport. The header lists the args as
// (time, moveview, seekplay) — moveview=false and seekplay=false are the
// non-disruptive choice; flagged in case the DAW shows otherwise.
// E. SetOnlyTrackSelected deselects all tracks and selects exactly one. The header
// doc-comment says "Set exactly one track selected, deselect all others" —
// this is the strongest confirmation we have; flagged for DAW-verification.
#include "shell/capture/insert.h" #include "shell/capture/insert.h"
@@ -57,7 +50,6 @@
namespace reasampler { namespace reasampler {
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
using capture::computeInsertMode; using capture::computeInsertMode;
using capture::normalizeSlashes; using capture::normalizeSlashes;
using capture::resolveBankFile; using capture::resolveBankFile;
@@ -67,11 +59,10 @@ namespace {
namespace fs = std::filesystem; namespace fs = std::filesystem;
// The current project's directory (mirrors bank_panel/capture/persist). The bank // Mirrors bank_panel/capture/persist's own derivation; the bank index stores
// index stores relative paths; resolving a bank file needs the current .rpp dir. // relative paths, so resolving a file needs the current .rpp dir. A shared "current
// FOLLOW-UP (already noted in panel_bank_ops.cpp): a shared "current project dir" // project dir" helper would be a clean small refactor now that a fourth consumer
// REAPER helper is a clean small refactor now that a fourth consumer exists — out // exists (also noted in panel_bank_ops.cpp) — out of scope here.
// of scope for M6.
std::string currentProjectDir() { std::string currentProjectDir() {
std::vector<char> buf(4096, '\0'); std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size())); EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
@@ -80,9 +71,8 @@ std::string currentProjectDir() {
return normalizeSlashes(fs::path(rpp).parent_path().string()); return normalizeSlashes(fs::path(rpp).parent_path().string());
} }
// Snapshot the user's currently-selected track set (ignores master, matches // Snapshot of the currently-selected track set (master is skipped, matching
// CountSelectedTracks / GetSelectedTrack which both skip master). Returns the // CountSelectedTracks/GetSelectedTrack), in selection order, for restore later.
// tracks in selection order so we can restore the original state afterward.
std::vector<MediaTrack*> snapshotSelectedTracks() { std::vector<MediaTrack*> snapshotSelectedTracks() {
const int n = CountSelectedTracks(nullptr); // nullptr = active project const int n = CountSelectedTracks(nullptr); // nullptr = active project
std::vector<MediaTrack*> tracks; std::vector<MediaTrack*> tracks;
@@ -92,12 +82,10 @@ std::vector<MediaTrack*> snapshotSelectedTracks() {
return tracks; return tracks;
} }
// Restore a previously-snapshotted track selection: deselect all (by setting the // Restores a snapshotted selection: deselect all via the first track, then
// first track alone) then re-select the full set. If the snapshot is empty we // re-select the rest. Empty snapshot -> no-op (guards an empty project).
// leave all tracks deselected; no-op guard handles a completely empty project.
void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) { void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) {
if (tracks.empty()) return; if (tracks.empty()) return;
// Deselect all via the first track, then re-add the rest.
SetOnlyTrackSelected(tracks[0]); SetOnlyTrackSelected(tracks[0]);
for (size_t i = 1; i < tracks.size(); ++i) for (size_t i = 1; i < tracks.size(); ++i)
SetTrackSelected(tracks[i], true); SetTrackSelected(tracks[i], true);
@@ -109,9 +97,8 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
InsertResult result; InsertResult result;
if (!session) { result.status = InsertStatus::NoSelection; return result; } if (!session) { result.status = InsertStatus::NoSelection; return result; }
// WHO to target: the user's currently-selected track set. No-op (with a clear // WHO: the user's selected track set. No-op when nothing is selected — inserting
// console message) when nothing is selected — inserting without a target track // without a target track would create an unintended track or behave unpredictably.
// would create an unintended new track or behave unpredictably.
const std::vector<MediaTrack*> selectedTracks = snapshotSelectedTracks(); const std::vector<MediaTrack*> selectedTracks = snapshotSelectedTracks();
if (selectedTracks.empty()) { if (selectedTracks.empty()) {
ShowConsoleMsg("ReaSampler insert: select a track first.\n"); ShowConsoleMsg("ReaSampler insert: select a track first.\n");
@@ -119,22 +106,20 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
return result; return result;
} }
// WHAT to place: the single focused sample from the panel. Multi-select is // WHAT: the single focused sample from the panel; multi-select is deprioritized,
// deprioritized; take the first (or only) selected id. An empty panel selection // so take the first id. Empty selection -> no-op.
// is a no-op — nothing to place.
const std::vector<std::string> ids = bankPanelSelectedSampleIds(); const std::vector<std::string> ids = bankPanelSelectedSampleIds();
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; } if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
const std::string& id = ids.front(); // focused / first selected — single sample const std::string& id = ids.front(); // focused / first selected — single sample
// WHERE the bank lives on disk. An unsaved project has no resolvable bank dir; // WHERE: an unsaved project has no resolvable bank dir; no-op rather than
// insert is a no-op rather than resolving against CWD (CLAUDE.md invariant). // resolving against CWD.
const std::string projectDir = currentProjectDir(); const std::string projectDir = currentProjectDir();
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; } if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
// Resolve the id against the bank the SELECTION came from — under B4's vertical // Resolve against the bank the selection came from — it may be the pool or a
// split the selection may live in the pool or a shown named bank, which is NOT // shown named bank, not necessarily the active/capture-target bank. Fall back to
// necessarily the active/capture-target bank. Fall back to the active bank when // the active bank when the source id names no bank (defensive).
// the source id names no bank (defensive).
const std::string srcBankId = bankPanelSelectedSourceBankId(); const std::string srcBankId = bankPanelSelectedSourceBankId();
const BankModel* srcIndex = session->book().index(srcBankId); const BankModel* srcIndex = session->book().index(srcBankId);
const BankModel& bank = srcIndex ? *srcIndex : session->bank(); const BankModel& bank = srcIndex ? *srcIndex : session->bank();
@@ -153,16 +138,14 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
// position for each track insert (and after the whole operation). // position for each track insert (and after the whole operation).
const double cursorPos = GetCursorPosition(); const double cursorPos = GetCursorPosition();
// Wrap the whole placement (all tracks + selection/cursor save-restore) in ONE // One undo block around the whole placement (all tracks + selection/cursor
// undo block so a single undo removes every item and restores the state before // restore) so a single undo removes every item and restores prior state. Always
// the action. Opened before the first InsertMedia, closed after the restore, // balanced — opened before the first insert, closed after the restore.
// unconditionally — the block is always balanced.
Undo_BeginBlock2(nullptr); Undo_BeginBlock2(nullptr);
// Insert onto EACH selected track at the SAME edit-cursor position (assumption B). // Insert onto each selected track at the same cursor position (assumption B): per
// For each track: isolate it as the only selection so InsertMedia mode 0 targets // track, isolate it as the only selection (A + E), reset the cursor (C is
// it unambiguously (assumption A + E), reset the cursor to the snapshot position // irrelevant since we own the reset), then insert.
// (assumption C cursor advance is irrelevant — we own the reset), then insert.
for (MediaTrack* track : selectedTracks) { for (MediaTrack* track : selectedTracks) {
SetOnlyTrackSelected(track); // assumption A + E SetOnlyTrackSelected(track); // assumption A + E
SetEditCurPos(cursorPos, false, false); // assumption D SetEditCurPos(cursorPos, false, false); // assumption D
@@ -170,14 +153,12 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
++result.inserted; ++result.inserted;
} }
// Restore the user's original track selection and cursor position so the action // Restore the original selection + cursor — non-destructive to the user's DAW state.
// is non-destructive to their DAW state (non-negotiable per the brief).
restoreSelectedTracks(selectedTracks); restoreSelectedTracks(selectedTracks);
SetEditCurPos(cursorPos, false, false); SetEditCurPos(cursorPos, false, false);
// Label reflects the count and the conform choice so the undo history reads // Label reflects count + conform choice for a clear undo history. extraflags -1 =
// clearly ("ReaSampler: insert on 2 tracks" etc.). extraflags -1 = UNDO_STATE_ALL // UNDO_STATE_ALL (tracks, items, envelope points, project state).
// (superset: tracks, items, envelope points, project state).
const std::string label = const std::string label =
"ReaSampler: insert on " + std::to_string(result.inserted) + "ReaSampler: insert on " + std::to_string(result.inserted) +
(result.inserted == 1 ? " track" : " tracks") + (result.inserted == 1 ? " track" : " tracks") +
+14 -18
View File
@@ -1,21 +1,17 @@
#pragma once #pragma once
// insert — placement of bank samples into the arrange (M6). REAPER-facing shell: // Placement of bank samples into the arrange. REAPER-facing shell: reads the
// it reads the bank_panel's current selection, resolves each selected sample's // bank_panel's current selection, resolves each selected sample's file, and drops
// file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped // it into the arrange at the edit cursor via InsertMedia, wrapped in an undo block.
// in an undo block.
// //
// THE INTENDED PLACEMENT PATH (CONTEXT.md §load-bearing principle): capture NEVER // The deliberate, user-invoked placement act (root CLAUDE.md §load-bearing
// auto-inserts; `insert` is the deliberate, user-invoked placement act, so it IS // principle: capture never auto-inserts) — must only ever run from its own action,
// allowed and expected to add items to the arrange. It must only ever run from its // never from a capture path.
// own action — never from a capture path.
// //
// Non-destructive to the bank: insert references the bank file (adds an arrange // Non-destructive to the bank: references the bank file, never modifies it or ext
// item pointing at it); it never modifies the bank, the bank files, or ext state. // state. No silent time-stretch: conform-to-tempo is an explicit opt-in, defaulting
// No SILENT time-stretch: conform-to-tempo is an explicit opt-in on the request, // off (native length) — see insert_plan for the mode-bit computation.
// defaulting OFF (native length). See insert_plan for the mode-bit computation.
// //
// The header is SDK-free: all REAPER API use lives in insert.cpp. The pure // SDK-free header; all REAPER API use lives in insert.cpp.
// mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW).
#include "core/capture/insert_plan.h" #include "core/capture/insert_plan.h"
@@ -32,16 +28,16 @@ struct InsertRequest {
// The outcome of an insert action, for the caller to log to the console. // The outcome of an insert action, for the caller to log to the console.
enum class InsertStatus { enum class InsertStatus {
Ok, // one or more samples inserted Ok,
NoSelection, // the panel had no selection — a no-op (not an error) NoSelection, // the panel had no selection — a no-op, not an error
NoProject, // no saved project, so no resolvable bank dir — no-op NoProject, // no saved project, so no resolvable bank dir — no-op
NothingResolved, // a selection existed but no sample resolved to a file NothingResolved, // a selection existed but no sample resolved to a file
}; };
struct InsertResult { struct InsertResult {
InsertStatus status = InsertStatus::NoSelection; InsertStatus status = InsertStatus::NoSelection;
int inserted = 0; // how many samples were actually placed int inserted = 0;
int skipped = 0; // selected-but-unresolvable/unreadable samples skipped int skipped = 0; // selected-but-unresolvable/unreadable samples
}; };
// Runs the insert: reads the bank panel's single focused sample and the user's // Runs the insert: reads the bank panel's single focused sample and the user's
+2 -2
View File
@@ -1,7 +1,7 @@
// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See // item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See
// item_read.h. Compiled into the reaper_reasampler MODULE; includes // item_read.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that
// defines the API pointers — CLAUDE.md §contract). // defines the API pointers).
#include "shell/capture/item_read.h" #include "shell/capture/item_read.h"
+9 -11
View File
@@ -1,16 +1,14 @@
#pragma once #pragma once
// item_read — the ONE place a MediaItem* is read for its canonical GUID string and for // The one place a MediaItem* is read for its canonical GUID string and for the
// the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and // durable P_LANENAME of the fixed lane it sits on — the item-read analog of
// bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair // track_guid's single MediaTrack* -> GUID-key formatter. Extracted from
// (both files' comments acknowledged the deliberate copy); the D2 Wave-3-B item actions // near-identical private itemGuid/itemLaneName pairs previously duplicated in
// need the same two reads, so the duplication is extracted here — the item-read analog // view.cpp and bank_panel.cpp.
// of track_guid's single MediaTrack* -> GUID-key formatter.
// //
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern — // (main.cpp owns the API pointers). MediaItem/MediaTrack are forward-declared so
// CLAUDE.md §contract). MediaItem / MediaTrack are forward-declared so this header // this header stays SDK-lite. These are shell reads; the managed/manual decision
// stays SDK-lite. These are shell reads (REAPER string/value getters); the managed/ // that consumes the lane name stays pure in lane_keys (isOnManualLane).
// manual DECISION that consumes the lane name stays pure in lane_keys (isOnManualLane).
#include <string> #include <string>
+7 -23
View File
@@ -1,23 +1,9 @@
// provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h. // provenance_shell.cpp — the REAPER reads behind provenance. See provenance_shell.h.
// Every REAPER symbol used here is verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers.
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// * TrackFX_GetCount(MediaTrack*) (~7283)
// * TrackFX_GetFXName(MediaTrack*, int, char*, int) -> bool (~7356)
// * TrackFX_GetFXGUID(MediaTrack*, int) -> GUID* (~7348)
// * TrackFX_GetEnabled(MediaTrack*, int) -> bool (~7291)
// * TakeFX_GetCount(MediaItem_Take*) (~6710)
// * TakeFX_GetFXName(MediaItem_Take*, int, char*, int) -> bool (~6758)
// * TakeFX_GetFXGUID(MediaItem_Take*, int) -> GUID* (~6750)
// * TakeFX_GetEnabled(MediaItem_Take*, int) -> bool (~6718)
// * CountSelectedMediaItems / GetSelectedMediaItem (selection reads)
// * GetActiveTake(MediaItem*) -> MediaItem_Take* (active take)
// * GetMediaItemTake_Source(MediaItem_Take*) -> PCM_source* (~2053)
// * GetMediaSourceFileName(PCM_source*, char*, int) (~2141)
// * CountTracks / GetTrack (track scan)
// * guidToString (via track_guid)
#include "shell/capture/provenance_shell.h" #include "shell/capture/provenance_shell.h"
@@ -51,7 +37,6 @@
namespace reasampler { namespace reasampler {
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
using capture::normalizeSlashes; using capture::normalizeSlashes;
using capture::resolveBankFile; using capture::resolveBankFile;
@@ -80,9 +65,8 @@ std::string fxChainIdentityForTrack(MediaTrack* tr) {
} }
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items) { std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items) {
// For Item scope the in-scope chain is each item's active take's FX chain, NOT // The owning track's chain is out of scope for an item capture (bypassed
// the owning track's FX chain (the track chain is out-of-scope and is bypassed // during render) — TakeFX_* on the active take is the correct family here.
// during render). TakeFX_* is the correct family here.
std::vector<std::string> perItem; std::vector<std::string> perItem;
perItem.reserve(items.size()); perItem.reserve(items.size());
for (MediaItem* it : items) { for (MediaItem* it : items) {
+29 -42
View File
@@ -1,19 +1,16 @@
#pragma once #pragma once
// provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place. // The REAPER-facing reads provenance needs, in one place. The pure provenance
// // module (provenance.h) owns the fingerprint encoding, the recipe model, the
// The PURE provenance module (provenance.h) owns the fingerprint encoding, the // FX-identity fold, and the parent-detection decision, all over plain
// recipe model, the FX-identity fold, and the parent-detection DECISION — all over // strings/values; this shell gathers those strings/values from REAPER:
// plain strings/values. This shell gathers those strings/values FROM REAPER:
// * the in-scope FX-chain identity of a source track (name/GUID/enabled rows), // * the in-scope FX-chain identity of a source track (name/GUID/enabled rows),
// * the media-file paths of a resolved capture's source items, // * the media-file paths of a resolved capture's source items,
// * the active book's bank samples resolved to absolute file paths, // * the active book's bank samples resolved to absolute file paths,
// * a canonical track-GUID string back to a live MediaTrack*. // * a canonical track-GUID string back to a live MediaTrack*.
// //
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern — // (main.cpp owns the API pointers). MediaTrack is forward-declared so this header
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays // stays SDK-lite.
// SDK-lite. It depends on the pure provenance module (FxIdentityEntry / recipe /
// BankFileRef) and bank_book (to enumerate the active book's samples).
#include <optional> #include <optional>
#include <string> #include <string>
@@ -28,53 +25,43 @@ namespace reasampler {
class BankBook; class BankBook;
// Real-namespace-home using-declaration (Q-W6: the namespaces.h shim is retired).
using model::BankFileRef; using model::BankFileRef;
// The in-scope FX-chain identity of a source track (Track scope), folded to the // The in-scope FX-chain identity of a source track (Track scope), folded to the
// pure provenance string. Reads the track's own FX chain via TrackFX_GetCount / // pure provenance string via the track's own FX chain (TrackFX_*) in chain order.
// TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order.
std::string fxChainIdentityForTrack(MediaTrack* tr); std::string fxChainIdentityForTrack(MediaTrack* tr);
// The in-scope FX-chain identity for Item scope: enumerates each item's active // The in-scope FX-chain identity for Item scope: enumerates each item's active
// take FX chain via TakeFX_GetCount / TakeFX_GetFXName / TakeFX_GetFXGUID / // take FX chain (TakeFX_*), in item order then FX order, combined with
// TakeFX_GetEnabled, in item order then FX order, combined with // combineChainIdentities so distinct per-item partitions never collide. `items` is
// combineChainIdentities so distinct per-item partitions never collide. Returns // the same source-item set the shell collected for the item-scope capture.
// the combined identity string (empty combined identity for a no-FX or no-item
// set). The items vector is the same source-item set the shell collected for the
// item-scope capture (selected items whose owning tracks were also collected).
std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items); std::string fxChainIdentityForItems(const std::vector<MediaItem*>& items);
// Reads the media-file path of every SELECTED media item's active take source // The media-file path of every selected media item's active take source,
// (GetMediaItemTake_Source -> GetMediaSourceFileName), normalized to forward-slash. // normalized to forward-slash. Unresolvable items (no take/source/name) are
// Unresolvable items (no take / no source / empty name) are omitted — never an // omitted — never an empty string in the result, so detectParent's "not in bank"
// empty string in the result, so detectParent's "not in bank" branch is honest. // branch is honest. This is the item-scope source set.
// The active-project selection is read directly (mirrors main.cpp's collectors).
// This is the ITEM-scope source set (the user selected the items being resampled).
std::vector<std::string> selectedItemSourceFiles(); std::vector<std::string> selectedItemSourceFiles();
// The TRACK-scope source set: the media-file paths of the items ON `tracks` that // The track-scope source set: media-file paths of the items on `tracks` that
// OVERLAP the capture range [startSeconds, endSeconds). For a track capture the user // overlap the capture range [startSeconds, endSeconds). A track capture selects
// selects the track, not the item, so the "what audio is being captured" set is the // the track, not the item, so this is what "the source audio" means for it. Same
// range-overlapping items on the source tracks. Same normalize + omit-unresolvable // normalize + omit-unresolvable contract as selectedItemSourceFiles; an item
// contract as selectedItemSourceFiles. An item overlaps iff its [pos, pos+len) // overlaps iff its [pos, pos+len) intersects the range with positive overlap (a
// intersects the range with positive overlap (a zero-length touch does not count). // zero-length touch does not count).
std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks, std::vector<std::string> trackItemSourceFiles(const std::vector<MediaTrack*>& tracks,
double startSeconds, double endSeconds); double startSeconds, double endSeconds);
// Enumerates the ACTIVE book's samples across every bank (pool + named) as pure // Enumerates the active book's samples across every bank as pure BankFileRefs,
// BankFileRefs — each sample id paired with its file resolved to a normalized // each id paired with its file resolved to an absolute path against `projectDir`.
// ABSOLUTE path against `projectDir` (resolveBankFile + normalizeSlashes). A sample // An unresolvable path (empty projectDir/relativePath) gets an empty absolutePath,
// whose path cannot be resolved (empty projectDir / empty relativePath) is emitted // which detectParent never matches.
// with an empty absolutePath, which detectParent never matches. `projectDir` is the
// current .rpp parent (the shell resolves it; empty -> all refs unresolved).
std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir); std::vector<BankFileRef> bankFileRefs(const BankBook& book, const std::string& projectDir);
// Resolves a canonical track-GUID string (guidString form) to a live MediaTrack* // Resolves a canonical track-GUID string to a live MediaTrack* in the active
// in the active project by scanning tracks and comparing guidString(tr). Returns // project. Returns nullptr when no live track carries that GUID (the source track
// nullptr when no live track carries that GUID (the source track was deleted since // was deleted since capture — a re-capture failure mode the caller reports). The
// capture — a re-capture failure mode the caller reports). The master track is not // master track is not scanned (no membership GUID, never a capture source).
// scanned (it has no membership GUID and is never a capture source).
MediaTrack* trackByGuid(const std::string& guid); MediaTrack* trackByGuid(const std::string& guid);
} // namespace reasampler } // namespace reasampler
+25 -32
View File
@@ -1,10 +1,9 @@
// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals // realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals.
// (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded as a // See the header.
// parameter). See the header.
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract). // pointers; here they are extern.
#include "shell/capture/realtime_lifecycle.h" #include "shell/capture/realtime_lifecycle.h"
@@ -17,15 +16,13 @@
namespace reasampler::capture { namespace reasampler::capture {
// --- M8 in-flight realtime capture (async, timer-driven) --------------------
RealtimeRecordBackend g_rtBackend; RealtimeRecordBackend g_rtBackend;
RealtimeCaptureHandle g_rtCapture; RealtimeCaptureHandle g_rtCapture;
ReaProject* g_rtCaptureProject = nullptr; ReaProject* g_rtCaptureProject = nullptr;
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the // Commits a finished realtime capture (a Done tick/abort with an Ok result): adds
// Sample to the ACTIVE bank (session.bank() resolves to book.activeIndex() — B2), // the Sample to the active bank, persists + MarkProjectDirty. Shared by the
// persist + MarkProjectDirty. Shared by the tick-completion path and the abort // tick-completion and abort paths. On a non-Ok result, logs the failure only.
// paths. On a non-Ok result, logs the failure only.
void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res) void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
{ {
if (res.status != CaptureStatus::Ok) if (res.status != CaptureStatus::Ok)
@@ -34,40 +31,36 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
return; return;
} }
session.bank().add(res.sample); session.bank().add(res.sample);
// B-cap: record the file the capture created in the owned-file manifest, at the same // Record the file in the owned manifest regardless of the index AddResult — even
// point the Sample is added and before the same persist. Recorded regardless of the // a hash-collapse still wrote a file the tool owns; the manifest dedups a repeat
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the // path itself (prune reconciles manifest vs index).
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
session.owned().add(res.sample.relativePath); session.owned().add(res.sample.relativePath);
// S9: a capture add changes what a live instance could play (a new sample landed in the // A capture add changes what a live instance could play, so bump the generation
// active bank) -> bump before the persist so the stamped generation refreshes instances. // before persisting to refresh instances.
session.bumpBankGeneration(); session.bumpBankGeneration();
session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty
} }
// Advance any in-flight realtime capture one tick. Cheap when none is running (a // Advances any in-flight realtime capture one tick. Detects a project switch
// null check) and fast even mid-record (tick() only reads the transport until the // mid-capture and aborts+restores so the capture never leaks across projects.
// terminal tick). Detects a project switch mid-capture and aborts+restores so the // Called from OnTimer before session.poll() so poll's own project-switch handling
// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so // sees an already-cleaned-up project.
// poll's project-switch handling sees a cleaned-up project.
void DriveRealtimeCapture(ReaSamplerSession& session) void DriveRealtimeCapture(ReaSamplerSession& session)
{ {
if (!g_rtCapture) return; if (!g_rtCapture) return;
// Project switch guard: if the active project is no longer the one the capture // If the active project is no longer the one the capture belongs to, a project
// belongs to, a new/other project became active mid-record abort + restore // switch happened mid-record: abort + restore into the original project the
// (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize // state is bound to, and drop it — never finalize into the new project.
// into the new project.
ReaProject* active = EnumProjects(-1, nullptr, 0); ReaProject* active = EnumProjects(-1, nullptr, 0);
if (active != g_rtCaptureProject) if (active != g_rtCaptureProject)
{ {
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
// Only commit if the ORIGINAL project is still open and active would be it — // Log without persisting — we restored into the original project but must
// on a switch we restored into the original but must not persist into the // not persist into the now-active foreign one. A Failed abort surfaces
// now-active foreign project. Log the outcome without persisting. On a Failed // abort()'s own message, distinguishing a clean tab-switch abort from the
// abort surface abort()'s own message — it distinguishes a clean tab-switch // closed-project case (nothing restored because the pointers were already
// abort from the closed-project DROP (the captured project was closed mid-record, // freed).
// review §1: nothing restored because the pointers were already freed).
if (r.status == RealtimeTickStatus::Done) if (r.status == RealtimeTickStatus::Done)
ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- "
"captured audio restored into the original project; not " "captured audio restored into the original project; not "
+20 -26
View File
@@ -1,20 +1,17 @@
#pragma once #pragma once
// realtime_lifecycle — the in-flight realtime-capture state machine + globals // The in-flight realtime-capture state machine + globals. A realtime record spans
// (Q-W3 hoist out of main.cpp). A realtime record spans many timer ticks (it takes // many timer ticks (it takes end-start wall-clock seconds and must not block
// end-start wall-clock seconds and must NOT block REAPER's UI): the action STARTS // REAPER's UI): the action starts it (RunCaptureRealtimeTrack -> g_rtBackend.begin),
// it (capture_orchestrator::RunCaptureRealtimeTrack -> g_rtBackend.begin), OnTimer // OnTimer drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a
// drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a
// terminal verdict, then the handle is cleared. // terminal verdict, then the handle is cleared.
// //
// The three globals are EXPOSED (extern) rather than wrapped: the action bodies in // The three globals are extern rather than wrapped so the timer's idle fast path
// capture_orchestrator manipulate them exactly as main.cpp did (zero-behavior-change // stays a single pointer test at the call site — load-bearing:
// move), and — load-bearing (CONTEXT.md §Phase Q hot-path guardrail) — the timer's
// IDLE FAST-PATH stays a SINGLE POINTER TEST at the call site:
// if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); // if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
// No per-tick cross-TU call, no accessor indirection, when nothing is recording. // No per-tick cross-TU call, no accessor indirection, when nothing is recording.
// //
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). // (main.cpp owns the API pointers).
#include "shell/capture/capture_realtime_shell.h" // RealtimeRecordBackend / RealtimeCaptureHandle #include "shell/capture/capture_realtime_shell.h" // RealtimeRecordBackend / RealtimeCaptureHandle
@@ -24,33 +21,30 @@ class ReaSamplerSession;
namespace reasampler::capture { namespace reasampler::capture {
// The realtime backend + the in-flight capture handle. Non-null handle == a // Non-null g_rtCapture == a capture is in progress: used to reject a second one,
// capture is in progress (used to reject a second one, to drive the per-tick // drive the per-tick advance, and abort on project switch / unload.
// advance, and to abort on project switch / unload).
extern RealtimeRecordBackend g_rtBackend; extern RealtimeRecordBackend g_rtBackend;
extern RealtimeCaptureHandle g_rtCapture; extern RealtimeCaptureHandle g_rtCapture;
// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets // The project the in-flight capture belongs to (opaque, compare-only) — lets
// OnTimer detect a project switch mid-capture and abort+restore rather than leak the // OnTimer detect a project switch mid-capture and abort+restore rather than leak the
// temp track/arm/transport into or across projects. Only meaningful when // temp track/arm/transport across projects. Meaningful only when g_rtCapture != nullptr.
// g_rtCapture != nullptr.
extern ReaProject* g_rtCaptureProject; extern ReaProject* g_rtCaptureProject;
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the // Commits a finished realtime capture (a Done tick/abort with an Ok result): adds
// Sample to the ACTIVE bank, record the owned file, bump the generation, persist + // the Sample to the active bank, records the owned file, bumps the generation,
// MarkProjectDirty. On a non-Ok result, logs the failure only. // persists + MarkProjectDirty. On a non-Ok result, logs the failure only.
void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res); void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res);
// Advance any in-flight realtime capture one tick. Cheap when none is running (a // Advances any in-flight realtime capture one tick. Detects a project switch
// null check — though the caller already guards, see the header note) and fast even // mid-capture and aborts+restores so the capture never leaks across projects.
// mid-record. Detects a project switch mid-capture and aborts+restores so the // Called from OnTimer before session.poll().
// capture never leaks across projects. Called from OnTimer BEFORE session.poll().
void DriveRealtimeCapture(ReaSamplerSession& session); void DriveRealtimeCapture(ReaSamplerSession& session);
// Unload teardown: abort any in-flight capture while the API pointers are still // Unload teardown: abort any in-flight capture while the API pointers are still
// live — finalize-or-abort + restore so we never leave a temp track, an armed // live — finalize-or-abort + restore so we never leave a temp track, an armed
// track, or an altered transport/cursor in the user's project on unload. Commits // track, or an altered transport/cursor behind. Commits whatever was captured
// whatever was captured (best effort) before tearing down. No-op when idle. // (best effort) before tearing down. No-op when idle.
void AbortRealtimeCaptureForUnload(ReaSamplerSession& session); void AbortRealtimeCaptureForUnload(ReaSamplerSession& session);
} // namespace reasampler::capture } // namespace reasampler::capture
+14 -34
View File
@@ -1,10 +1,9 @@
// scope_resolve.cpp — scope/source resolution for the capture action family // scope_resolve.cpp — scope/source resolution for the capture action family. See
// (Q-W3 hoist out of main.cpp; the code moved verbatim, session state threaded as // the header.
// parameters). See the header.
// //
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract). // pointers; here they are extern.
#include "shell/capture/scope_resolve.h" #include "shell/capture/scope_resolve.h"
@@ -50,8 +49,7 @@ model::ProvenanceScope provenanceScopeFor(CaptureScope scope)
// Collects the tracks that own the selected items (Item scope) into // Collects the tracks that own the selected items (Item scope) into
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an // out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning // item capture hears take/item FX only. GUIDs recorded for provenance.
// track (SDK header, verify). GUIDs recorded for provenance.
bool collectSelectedItemTracks(ResolvedSource& out) bool collectSelectedItemTracks(ResolvedSource& out)
{ {
const int n = CountSelectedMediaItems(nullptr); const int n = CountSelectedMediaItems(nullptr);
@@ -76,9 +74,8 @@ bool collectSelectedItemTracks(ResolvedSource& out)
} // namespace } // namespace
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of // Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), // start, end, envGuidString) and returns the union of parsed track-audio areas.
// and returns the union bound. Reads only — never clears the razor selection. // Reads only — never clears the razor selection.
// Returns false when no track-audio razor area exists on any track.
bool resolveRazorRange(double& start, double& end) bool resolveRazorRange(double& start, double& end)
{ {
std::vector<RazorRange> allRanges; std::vector<RazorRange> allRanges;
@@ -100,9 +97,6 @@ bool resolveRazorRange(double& start, double& end)
return end > start; return end > start;
} }
// Infers the render RANGE for any scope: razor union when a razor area is present,
// else the time selection (pure inferRangeSource decides which). Orthogonal to
// scope. Returns false (with a reason) when neither yields a non-empty range.
bool resolveRange(double& start, double& end, std::string& why) bool resolveRange(double& start, double& end, std::string& why)
{ {
double rzStart = 0.0, rzEnd = 0.0; double rzStart = 0.0, rzEnd = 0.0;
@@ -117,7 +111,6 @@ bool resolveRange(double& start, double& end, std::string& why)
return false; return false;
} }
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
bool collectSelectedTracks(ResolvedSource& out) bool collectSelectedTracks(ResolvedSource& out)
{ {
const int n = CountSelectedTracks(nullptr); // nullptr = active project const int n = CountSelectedTracks(nullptr); // nullptr = active project
@@ -133,8 +126,6 @@ bool collectSelectedTracks(ResolvedSource& out)
return !out.sourceTracks.empty(); return !out.sourceTracks.empty();
} }
// Resolves the source for a scope: the selection tracks (item/track), plus the
// inferred range. Returns false with a reason on nothing to do.
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why) bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
{ {
switch (scope) switch (scope)
@@ -153,11 +144,8 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
return resolveRange(out.startSeconds, out.endSeconds, why); return resolveRange(out.startSeconds, out.endSeconds, why);
} }
// Current project's directory (parent of its .rpp), forward-slashed, no trailing // Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
// slash — the same derivation capture.cpp does internally, needed here so M10 can // makes every bank file resolve empty -> no false parentage.
// resolve the bank's relative paths to absolute for parent detection. Empty for an
// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank
// file resolve empty -> no false parentage. Read-only; mutates nothing.
std::string currentProjectDir() std::string currentProjectDir()
{ {
std::vector<char> buf(4096, '\0'); std::vector<char> buf(4096, '\0');
@@ -171,16 +159,8 @@ std::string currentProjectDir()
return dir; return dir;
} }
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank // Detection rule: the capture's source item media file(s) must all resolve, by
// sample, else returns nullopt (the common, non-resample case). Detection rule // exact normalized absolute path, to one bank sample's file.
// (stated honestly): the capture's source item media file(s) must all resolve, by
// exact normalized absolute path, to ONE bank sample's file (detectParent). On a
// match, records that sample's id as the parent plus a THIN capture-recipe
// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels +
// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from
// source" can replay the request and report drift. NEVER a serialized chain to
// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per
// selected item, combined in item order; Track scope reads the track FX chain.
std::optional<model::Provenance> buildCaptureProvenance( std::optional<model::Provenance> buildCaptureProvenance(
const BankBook& book, const CaptureRequest& req, const BankBook& book, const CaptureRequest& req,
CaptureScope scope, const ResolvedSource& src) CaptureScope scope, const ResolvedSource& src)
@@ -188,9 +168,9 @@ std::optional<model::Provenance> buildCaptureProvenance(
const std::string projectDir = currentProjectDir(); const std::string projectDir = currentProjectDir();
const std::vector<model::BankFileRef> bankFiles = bankFileRefs(book, projectDir); const std::vector<model::BankFileRef> bankFiles = bankFileRefs(book, projectDir);
// The "what audio is being captured" source set depends on scope: item scope uses // What "the source audio" means depends on scope: item scope uses the selected
// the SELECTED items (the user picked them); track scope uses the range-overlapping // items (the user picked them); track scope uses the range-overlapping items on
// items ON the source tracks (the user picked the track, not the item). // the source tracks (the user picked the track, not the item).
const std::vector<std::string> sourceFiles = const std::vector<std::string> sourceFiles =
scope == CaptureScope::Item scope == CaptureScope::Item
? selectedItemSourceFiles() ? selectedItemSourceFiles()
+19 -24
View File
@@ -1,18 +1,15 @@
#pragma once #pragma once
// scope_resolve — scope/source resolution for the capture action family (Q-W3 // Scope/source resolution shared by every capture entry point. Three concerns:
// hoist out of main.cpp). The three concerns every capture entry point shares: // * range inference — razor union else time selection, orthogonal to scope;
// * RANGE inference — razor union else time selection (razor-else-time), // * source-track collection — selected tracks (Track scope) or selected items'
// orthogonal to scope; // owning tracks (Item scope), deduped, with canonical GUIDs;
// * SOURCE-TRACK collection — the selected tracks (Track scope) or the selected // * provenance-assembly inputs — resample-from-sample detection + the thin
// items' owning tracks (Item scope), deduped, with canonical GUIDs; // capture-recipe fingerprint built from the live (un-bypassed) chain.
// * PROVENANCE ASSEMBLY inputs — the M10 resample-from-sample detection + the
// thin capture-recipe fingerprint built from the LIVE (un-bypassed) chain.
// //
// All reads are non-destructive: selection, razor, and time selection are read, // All reads are non-destructive: selection, razor, and time selection are read,
// never mutated. REAPER-facing: the .cpp includes reaper_plugin_functions.h // never mutated. The .cpp includes reaper_plugin_functions.h WITHOUT
// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md // REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). MediaTrack is forward-
// §contract). MediaTrack is forward-declared (via capture.h) so this header stays // declared (via capture.h) so this header stays SDK-lite.
// SDK-lite.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -35,18 +32,16 @@ struct ResolvedSource
{ {
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
std::vector<MediaTrack*> sourceTracks; // item-owning tracks / selected tracks std::vector<MediaTrack*> sourceTracks;
std::vector<std::string> trackGuids; // canonical GUIDs of sourceTracks std::vector<std::string> trackGuids;
}; };
// Reads every track's P_RAZOREDITS, parses the track-audio areas (pure // Reads every track's P_RAZOREDITS and returns the union of parsed track-audio
// parseRazorEdits), and returns the union bound. Reads only — never clears the // areas. Reads only — never clears the razor selection.
// razor selection. Returns false when no track-audio razor area exists on any track.
bool resolveRazorRange(double& start, double& end); bool resolveRazorRange(double& start, double& end);
// Infers the render RANGE for any scope: razor union when a razor area is present, // Infers the render range for any scope: razor union when present, else the time
// else the time selection (pure inferRangeSource decides which). Orthogonal to // selection. Returns false with a reason when neither yields a non-empty range.
// scope. Returns false (with a reason) when neither yields a non-empty range.
bool resolveRange(double& start, double& end, std::string& why); bool resolveRange(double& start, double& end, std::string& why);
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. // Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
@@ -60,10 +55,10 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
// slash. Empty for an unsaved project (no false parentage). Read-only. // slash. Empty for an unsaved project (no false parentage). Read-only.
std::string currentProjectDir(); std::string currentProjectDir();
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank // Builds the provenance for a capture if it genuinely resamples from a bank sample
// sample (detectParent over `book`'s resolved file refs), else returns nullopt (the // (detectParent over `book`'s resolved file refs), else returns nullopt (the common,
// common, non-resample case). Must run BEFORE the FxBypassGuard neutralizes the // non-resample case). Must run BEFORE the FxBypassGuard neutralizes the in-scope
// in-scope chain — the source FX-chain identity is read from the LIVE chain. // chain — the source FX-chain identity is read from the live chain.
std::optional<model::Provenance> buildCaptureProvenance( std::optional<model::Provenance> buildCaptureProvenance(
const BankBook& book, const CaptureRequest& req, const BankBook& book, const CaptureRequest& req,
CaptureScope scope, const ResolvedSource& src); CaptureScope scope, const ResolvedSource& src);
+2 -2
View File
@@ -1,7 +1,7 @@
// track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See // track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See
// track_guid.h. Compiled into the reaper_reasampler MODULE; includes // track_guid.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU
// that defines the API pointers — CLAUDE.md §contract). // that defines the API pointers).
#include "shell/capture/track_guid.h" #include "shell/capture/track_guid.h"
+7 -8
View File
@@ -1,13 +1,12 @@
#pragma once #pragma once
// track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // The one place a MediaTrack* is formatted into the canonical GUID string used as
// string used as a membership-index key. Both the Design View shell (view.cpp) and // a membership-index key. Both the Design View shell (view.cpp) and the actions
// the actions layer (design_view_actions.cpp) key membership on this exact string, so the key // layer (design_view_actions.cpp) key membership on this exact string, so the
// contract lives in a single helper rather than being re-derived (and drifting) at // contract lives in a single helper rather than being re-derived at two call sites.
// two call sites (the cross-module key contract flagged in D2 review).
// //
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT // The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern — // (main.cpp owns the API pointers). MediaTrack is forward-declared so this header
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays SDK-lite. // stays SDK-lite.
#include <string> #include <string>