M3: offline-render capture spike (master-mix / time-selection)

ICaptureBackend/CaptureRequest seam + OfflineRenderBackend driving snapshotted
RENDER_* with dither/normalize forced off for bit-identical output, RenderFailed
via filesystem check, and a pure capture_paths lib with tests. Spike action registered.
This commit is contained in:
2026-07-22 13:07:31 -04:00
parent 93e2783098
commit d9ad8e4adf
7 changed files with 837 additions and 26 deletions
+397
View File
@@ -0,0 +1,397 @@
// capture.cpp — REAPER-facing offline-render backend (M3 spike).
//
// 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).
//
// Scope (M3): ONE source mode — the time-selection master mix. Drives the
// RENDER_* project settings via GetSetProjectInfo / _String, snapshots and
// restores every setting it changes (non-destructive), triggers a no-dialog
// render, then populates a Sample. It NEVER inserts into the arrange
// (load-bearing principle).
#include "capture.h"
#include <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <string>
#include <vector>
#include "capture_paths.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjectPathEx
#define REAPERAPI_WANT_GetSetProjectInfo
#define REAPERAPI_WANT_GetSetProjectInfo_String
#define REAPERAPI_WANT_GetSet_LoopTimeRange
#define REAPERAPI_WANT_Main_OnCommand
#define REAPERAPI_WANT_Master_GetTempo
#define REAPERAPI_WANT_ShowConsoleMsg
#include "reaper_plugin_functions.h"
namespace reasampler {
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.
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.
constexpr double kBoundsCustom = 0.0;
// RENDER_SETTINGS master-mix bit pattern. Per the SDK header (line ~3041):
// (&(1|2))==0 => master mix, &8=use render matrix. We want plain master mix:
// no stems (bits 1|2 clear), no render matrix. Value 0 = master mix, no matrix.
constexpr double kRenderSettingsMasterMix = 0.0;
// RENDER_TAILFLAG bit &1 = apply tail for custom time bounds. We clear it for
// the spike (exact bounds, no added silence — precision invariant).
constexpr double kTailFlagNone = 0.0;
constexpr double kTailFlagCustomBounds = 1.0; // &1, used only if renderTail set
// 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.
constexpr double kDitherDisableAll = 16.0;
// RENDER_NORMALIZE disable-all: &(4<<16) = disable all render postprocessing.
// Verified: SDK header line ~3051: "(&(4<<16))==disable all render postprocessing".
// This masks out normalization, brickwall, fades, pad/trim — every post-process
// that is nondeterministic relative to the source signal.
constexpr double kNormalizeDisableAll = static_cast<double>(4 << 16); // 262144
// --- WAV render sink configuration ------------------------------------------
//
// 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.
//
// DAW-ONLY ASSUMPTION: RENDER_FORMAT takes a base64-encoded sink config, OR
// (per SDK header line ~3114) a simple 4-byte string to use *default* settings
// for that sink type. The WAV sink fourcc is "evaw" ("wave" little-endian).
//
// The 4-byte "evaw" fallback is header-DOCUMENTED and safe, but it inherits the
// USER'S default WAV bit depth — not deterministic across machines. To PIN the
// bit depth we build the full WAV sink config blob below. That blob's byte
// layout is REAPER-INTERNAL and is NOT in the SDK header, so it cannot be
// verified here and MUST be confirmed in a running REAPER (capture, then inspect
// the wav header / render stats). If the pinned blob proves wrong on the live
// build, the safe fallback is to write the 4 raw bytes {'e','v','a','w'} to
// RENDER_FORMAT (header-documented default WAV settings) and set the project's
// default WAV depth to float once by hand.
//
// Known REAPER WAV config layout (community-documented, unverifiable here):
// char[4] fourcc = 'evaw'
// int32 bit-depth field: 0=8bit,1=16bit,2=24bit,3=32bit int,4=32bit float(*)
// int32 flags (0 = defaults: little-endian, no BWF/loop metadata)
// (*) the exact float encoding is the single value most needing live confirmation.
void appendInt32LE(std::vector<char>& out, std::int32_t v) {
out.push_back(static_cast<char>(v & 0xFF));
out.push_back(static_cast<char>((v >> 8) & 0xFF));
out.push_back(static_cast<char>((v >> 16) & 0xFF));
out.push_back(static_cast<char>((v >> 24) & 0xFF));
}
// Full WAV sink config with the bit depth pinned. See the DAW-ONLY ASSUMPTION
// above — the depth-code mapping is the load-bearing unknown to confirm live.
std::vector<char> wavSinkConfigPinned(WavBitDepth depth) {
std::int32_t depthCode = 4; // default: 32-bit float
switch (depth) {
case WavBitDepth::Int16: depthCode = 1; break;
case WavBitDepth::Int24: depthCode = 2; break;
case WavBitDepth::Float32: depthCode = 4; break;
}
std::vector<char> cfg = {'e', 'v', 'a', 'w'};
appendInt32LE(cfg, depthCode);
appendInt32LE(cfg, 0); // flags: defaults (LE, no metadata)
return cfg;
}
// --- 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.
struct RenderSettingsSnapshot {
ReaProject* proj = nullptr;
// Numeric settings (GetSetProjectInfo).
double boundsFlag = 0.0;
double startPos = 0.0;
double endPos = 0.0;
double tailFlag = 0.0;
double tailMs = 0.0;
double srate = 0.0;
double channels = 0.0;
double renderSettings = 0.0;
double addToProj = 0.0;
double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored
double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored
// String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
// full value in, and RENDER_FORMAT is a base64 blob that can be long.
std::string renderFile;
std::string renderPattern;
std::string renderFormat;
bool captured = false;
};
std::string getProjString(ReaProject* proj, const char* desc) {
std::vector<char> buf(4096, '\0');
GetSetProjectInfo_String(proj, desc, buf.data(), false);
return std::string(buf.data());
}
void setProjString(ReaProject* proj, const char* desc, const std::string& value) {
// GetSetProjectInfo_String takes a non-const char*; copy into a mutable buf.
std::vector<char> buf(value.begin(), value.end());
buf.push_back('\0');
GetSetProjectInfo_String(proj, desc, buf.data(), true);
}
void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
s.proj = proj;
s.boundsFlag = GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", 0.0, false);
s.startPos = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false);
s.endPos = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false);
s.tailFlag = GetSetProjectInfo(proj, "RENDER_TAILFLAG", 0.0, false);
s.tailMs = GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, false);
s.srate = GetSetProjectInfo(proj, "RENDER_SRATE", 0.0, false);
s.channels = GetSetProjectInfo(proj, "RENDER_CHANNELS", 0.0, false);
s.renderSettings = GetSetProjectInfo(proj, "RENDER_SETTINGS", 0.0, false);
s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
s.renderFile = getProjString(proj, "RENDER_FILE");
s.renderPattern = getProjString(proj, "RENDER_PATTERN");
s.renderFormat = getProjString(proj, "RENDER_FORMAT");
s.captured = true;
}
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);
GetSetProjectInfo(s.proj, "RENDER_BOUNDSFLAG", s.boundsFlag, true);
GetSetProjectInfo(s.proj, "RENDER_STARTPOS", s.startPos, true);
GetSetProjectInfo(s.proj, "RENDER_ENDPOS", s.endPos, true);
GetSetProjectInfo(s.proj, "RENDER_TAILFLAG", s.tailFlag, true);
GetSetProjectInfo(s.proj, "RENDER_TAILMS", s.tailMs, true);
GetSetProjectInfo(s.proj, "RENDER_SRATE", s.srate, true);
GetSetProjectInfo(s.proj, "RENDER_CHANNELS", s.channels, true);
GetSetProjectInfo(s.proj, "RENDER_SETTINGS", s.renderSettings, true);
GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
}
// RAII wrapper: guarantees restore on every return path from capture().
struct ScopedRenderSettings {
RenderSettingsSnapshot snap;
explicit ScopedRenderSettings(ReaProject* proj) {
snapshotRenderSettings(snap, proj);
}
~ScopedRenderSettings() { restoreRenderSettings(snap); }
ScopedRenderSettings(const ScopedRenderSettings&) = delete;
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
};
// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
// do not collide on the file name. 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.
std::string makeUniqueTag() {
std::time_t now = std::time(nullptr);
return std::to_string(static_cast<long long>(now));
}
} // namespace
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
CaptureResult result;
// M3 implements only the master-mix / time-selection case. Both mean "render
// the master mix over the requested bounds".
if (request.sourceMode != SourceMode::MasterMix &&
request.sourceMode != SourceMode::TimeSelection) {
result.status = CaptureStatus::UnsupportedMode;
result.message = "OfflineRenderBackend (M3) supports only master-mix / "
"time-selection capture.";
return result;
}
// Exact bounds: reject an empty/inverted range rather than render silence.
if (!(request.endSeconds > request.startSeconds)) {
result.status = CaptureStatus::EmptyRange;
result.message = "Capture range is empty (end <= start).";
return result;
}
// Current project (idx -1 == the active project tab). Verified: SDK header
// line ~1264, EnumProjects(int idx, char*, int).
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (!proj) {
result.status = CaptureStatus::NoProject;
result.message = "No active project.";
return result;
}
// Resolve the project directory. GetProjectPathEx writes the effective
// recording/project path (absolute). Verified: SDK header line ~2550,
// GetProjectPathEx(ReaProject*, char* bufOut, int bufOut_sz).
std::vector<char> projPathBuf(4096, '\0');
GetProjectPathEx(proj, projPathBuf.data(), static_cast<int>(projPathBuf.size()));
const std::string projectDir(projPathBuf.data());
if (projectDir.empty()) {
result.status = CaptureStatus::NoProject;
result.message = "Project has no path yet (save the project first).";
return result;
}
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
// timestamp. Calling makeUniqueTag() twice could yield different values if a
// second boundary crosses between the two calls (bug: id and filename diverge).
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);
if (request.renderTail) {
GetSetProjectInfo(proj, "RENDER_TAILFLAG", kTailFlagCustomBounds, true);
GetSetProjectInfo(proj, "RENDER_TAILMS", request.tailMs, true);
} else {
GetSetProjectInfo(proj, "RENDER_TAILFLAG", kTailFlagNone, true);
GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, true);
}
// Master mix, no stems, no render matrix.
GetSetProjectInfo(proj, "RENDER_SETTINGS", kRenderSettingsMasterMix, true);
// 0 sampleRate => follow the (fixed) project rate. Channel count preserved
// (no silent stereo fold — precision invariant).
GetSetProjectInfo(proj, "RENDER_SRATE",
static_cast<double>(request.sampleRate), true);
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.
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
// Determinism: disable dither and all render post-processing 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).
// RENDER_NORMALIZE &(4<<16) = disable all render postprocessing (line ~3051).
// Both are snapshotted above and restored by the RAII guard on every path.
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
GetSetProjectInfo(proj, "RENDER_NORMALIZE", kNormalizeDisableAll, 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.
setProjString(proj, "RENDER_FILE", paths.absoluteDir);
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
// Pin the WAV format to the chosen bit depth (float by default). See the
// wavSinkConfigPinned DAW-ONLY ASSUMPTION.
const std::vector<char> fmt = wavSinkConfigPinned(request.bitDepth);
{
std::vector<char> fmtBuf(fmt.begin(), fmt.end());
fmtBuf.push_back('\0');
GetSetProjectInfo_String(proj, "RENDER_FORMAT", fmtBuf.data(), true);
}
// --- Trigger the headless render ----------------------------------------
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
// the render synchronously with no dialog on the current build.
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.
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.
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.
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.
s.wetDry = request.wetDry;
s.channelCount = request.channelCount;
// DEFERRED (M6/M7): sampleRate stored as 0 = "follow project rate". Resolving
// the actual project sample rate via PROJECT_SRATE/PROJECT_SRATE_USE would
// require a live REAPER to verify. For the M3 spike this is intentional:
// the project rate is fixed for a given project and the render inherits it.
s.sampleRate = request.sampleRate; // 0 == follow project rate
s.lengthSeconds = request.endSeconds - request.startSeconds;
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
s.tier = Tier::Scratch; // captures land in scratch by default
// contentHash left empty for M3: hashing the rendered file is a peaks/M2-
// adjacent concern wired in a later milestone. Empty hashes do NOT dedup, so
// this is safe (bank_model treats "" as non-participating).
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
result.status = CaptureStatus::Ok;
result.sample = s;
result.message = "Captured master mix [" +
std::to_string(request.startSeconds) + "s, " +
std::to_string(request.endSeconds) + "s] -> " +
paths.relativePath;
return result;
// guard's dtor restores every RENDER_* setting here.
}
} // namespace reasampler