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