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

This commit is contained in:
2026-07-29 20:48:59 -04:00
parent 1f24c4b095
commit 54f5f24506
22 changed files with 699 additions and 1215 deletions
+109 -261
View File
@@ -1,38 +1,22 @@
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus
// 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<unsigned long long> counter{0};
const std::time_t now = std::time(nullptr);
return prefix + std::to_string(static_cast<long long>(now)) + "-" +
@@ -259,26 +205,19 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
s.trackGuids = req.trackGuids;
s.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<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false));
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
s.captureTempo = Master_GetTempo(); // BPM at capture time
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
// (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<std::uint8_t> 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<char> buf(4096, '\0');
// EnumProjects(-1, ...) returns the active project and writes the .rpp
// path into buf. We already have the ReaProject* from the earlier call
// (nullptr-checked above), but calling EnumProjects again is the only
// stock, header-documented way to read the .rpp filename.
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
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<double>(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<double>(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<int>(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<double>(effectiveSampleRate), true);
@@ -469,100 +351,67 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_CHANNELS",
static_cast<double>(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<double>(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