09a85737a8
GetProjectPathEx returns REAPER's recording path (never empty); EnumProjects returns the .rpp path (empty when unsaved) — the correct signal. Bank now derives from the .rpp parent so it sits alongside the project file. Render progress window documented as inherent to REAPER offline render.
477 lines
24 KiB
C++
477 lines
24 KiB
C++
// 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 render, then
|
|
// populates a Sample. It NEVER inserts into the arrange (load-bearing principle).
|
|
//
|
|
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
|
|
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
|
|
// its offline-render progress dialog (progress bar + waveform view) for the
|
|
// duration of the render. The RENDER_SETTINGS bits documented in
|
|
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
|
|
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
|
|
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
|
|
// from showing this UI for an offline file render triggered via Main_OnCommand.
|
|
// This is inherent to REAPER's offline render path. The dialog-free alternative
|
|
// is the realtime-record backend (M8), which captures the master bus output to a
|
|
// temp track during playback and never invokes the offline render pipeline.
|
|
|
|
#include "capture.h"
|
|
|
|
#include <cstdint>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "capture_paths.h"
|
|
|
|
#define REAPERAPI_MINIMAL
|
|
#define REAPERAPI_WANT_EnumProjects
|
|
#define REAPERAPI_WANT_GetSetProjectInfo
|
|
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
|
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
|
#define REAPERAPI_WANT_Main_OnCommand
|
|
#define REAPERAPI_WANT_Main_SaveProject
|
|
#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.
|
|
//
|
|
// 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).
|
|
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.
|
|
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
|
|
}
|
|
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.
|
|
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 from the .rpp file path.
|
|
//
|
|
// 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.
|
|
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.
|
|
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
|
|
// 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);
|
|
|
|
// 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.
|
|
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.
|
|
if (effectiveSampleRate > 0) {
|
|
GetSetProjectInfo(proj, "RENDER_SRATE",
|
|
static_cast<double>(effectiveSampleRate), 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 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.
|
|
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+).";
|
|
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.
|
|
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;
|
|
// Store the resolved sample rate only when it is known (> 0). If the project
|
|
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
|
|
// either, so the render ran at REAPER's project default — an unknown value from
|
|
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
|
|
// than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
|
|
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
|
|
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
|