2a9ab65944
Corrected the Render-dialog source name the refusal's evidence depends on, added a by-ear content check and a file-size channel proxy, and stopped two comments from overclaiming.
594 lines
29 KiB
C++
594 lines
29 KiB
C++
// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared
|
|
// backend helpers (makeUniqueTag / captureNameFor / collapseCapturedFileToMono /
|
|
// stampCaptureSample).
|
|
//
|
|
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
|
|
// the one TU that defines the API pointers; here they are extern.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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"
|
|
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <system_error>
|
|
#include <vector>
|
|
|
|
#include "core/capture/capture_paths.h"
|
|
#include "core/capture/wav_codec.h" // hashWavContent / collapseToMono — the one WAV/RIFF owner
|
|
#include "core/util/file_bytes.h"
|
|
#include "core/capture/render_settings.h"
|
|
#include "core/capture/render_window.h" // frameCountFor — the exact-bounds number
|
|
|
|
#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
|
|
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
|
#include "reaper_plugin_functions.h"
|
|
|
|
namespace reasampler::capture {
|
|
|
|
namespace {
|
|
|
|
// 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, so it cannot be verified against the SDK header
|
|
// (offline capture shipping as the default is the empirical evidence it holds).
|
|
// Renders headlessly using whatever RENDER_* settings are currently on the
|
|
// project — why we set them all explicitly first.
|
|
constexpr int kActionRenderUsingMostRecentSettings = 42230;
|
|
|
|
// 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/TAILMS/NORMALIZE/TRIMEND are driven from the pure
|
|
// tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below.
|
|
|
|
// 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;
|
|
|
|
// 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 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; // capture ground-truth blob first
|
|
case WavBitDepth::Int24: return nullptr; // capture ground-truth blob first
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold 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.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 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;
|
|
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);
|
|
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, 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;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
std::string makeUniqueTag(const std::string& prefix) {
|
|
// 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)) + "-" +
|
|
std::to_string(++counter);
|
|
}
|
|
|
|
CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
|
int ordinal, const std::string& fallback) {
|
|
CaptureNameInputs in;
|
|
in.sourceNames = sourceNames;
|
|
in.ordinal = ordinal;
|
|
in.fallback = fallback;
|
|
|
|
// localtime, not gmtime: the discriminator is read by the person who made the
|
|
// capture, so it must match the clock on their wall. A failed conversion leaves the
|
|
// stamp zeroed, which composeCaptureName renders as no discriminator at all.
|
|
const std::time_t now = std::time(nullptr);
|
|
std::tm local{};
|
|
#ifdef _WIN32
|
|
const bool ok = (localtime_s(&local, &now) == 0);
|
|
#else
|
|
const bool ok = (localtime_r(&now, &local) != nullptr);
|
|
#endif
|
|
if (ok) {
|
|
in.stamp.month = local.tm_mon + 1; // tm_mon is 0-based
|
|
in.stamp.day = local.tm_mday;
|
|
in.stamp.hour = local.tm_hour;
|
|
in.stamp.minute = local.tm_min;
|
|
}
|
|
return composeCaptureName(in);
|
|
}
|
|
|
|
namespace {
|
|
|
|
// One console line per genuine collapse failure — silence here is what made a failed
|
|
// rewrite read exactly like a legitimately stereo capture. Deliberately does NOT claim
|
|
// the captured bytes are intact: a 0-byte render can reach this branch too (it passes the
|
|
// exists/bounds gates upstream; see docs/TODO.md), and this path never verified the bytes
|
|
// it's reporting on.
|
|
void reportCollapseFailure(const std::string& absolutePath, const char* what,
|
|
const char* consoleLabel) {
|
|
ShowConsoleMsg((std::string(consoleLabel) + ": the lossless mono collapse " +
|
|
std::string(what) + " -- " + absolutePath +
|
|
" already reached the bank; only the size win from the collapse "
|
|
"was lost.\n").c_str());
|
|
}
|
|
|
|
} // namespace
|
|
|
|
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath,
|
|
const char* consoleLabel) {
|
|
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
|
|
if (bytes.empty()) {
|
|
// Failed, not Declined: the read that would have decided never happened, so
|
|
// "the channels differ" is a claim this path cannot make.
|
|
reportCollapseFailure(absolutePath, "could not read the captured file", consoleLabel);
|
|
return MonoCollapseOutcome::Failed;
|
|
}
|
|
|
|
const MonoCollapse collapse = collapseToMono(bytes);
|
|
if (!collapse.collapsed) return MonoCollapseOutcome::Declined;
|
|
|
|
// Sibling temp + rename, NOT an in-place truncating write: this runs unconditionally
|
|
// on the deterministic offline path (which never reopened its render for write before
|
|
// this step existed), so a mid-write failure here must not land a truncated file that
|
|
// stampCaptureSample then hashes as a false CaptureStatus::Ok. rename() replaces the
|
|
// destination in one step, so the original bytes are never destroyed until the
|
|
// replacement is known-complete; a failed write or rename leaves the original file
|
|
// untouched and self-cleans the temp rather than littering it.
|
|
const std::string tempPath = absolutePath + ".moncollapse.tmp";
|
|
{
|
|
std::ofstream out(tempPath, std::ios::binary | std::ios::trunc);
|
|
if (!out) {
|
|
reportCollapseFailure(absolutePath, "could not open its temporary file", consoleLabel);
|
|
return MonoCollapseOutcome::Failed;
|
|
}
|
|
out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
|
|
static_cast<std::streamsize>(collapse.bytes.size()));
|
|
const bool wroteOk = static_cast<bool>(out);
|
|
out.close();
|
|
if (!wroteOk) {
|
|
std::error_code ec;
|
|
std::filesystem::remove(tempPath, ec);
|
|
reportCollapseFailure(absolutePath, "could not write the rebuilt file", consoleLabel);
|
|
return MonoCollapseOutcome::Failed;
|
|
}
|
|
}
|
|
std::error_code ec;
|
|
std::filesystem::rename(tempPath, absolutePath, ec);
|
|
if (ec) {
|
|
std::filesystem::remove(tempPath, ec); // don't leave litter on a failed rename
|
|
reportCollapseFailure(absolutePath, "could not replace the captured file", consoleLabel);
|
|
return MonoCollapseOutcome::Failed;
|
|
}
|
|
return MonoCollapseOutcome::Collapsed;
|
|
}
|
|
|
|
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
|
ReaProject* rateProj, ReaProject* timeSigProj,
|
|
const std::string& absolutePath) {
|
|
// Track GUIDs echoed from the request (the caller resolved the selection; the
|
|
// backends stay source-agnostic). channelCount starts at 0 (unknown, the same
|
|
// sentinel bank_model already uses for a pre-field entry) rather than the
|
|
// request's value — the request always asks for 2, so echoing it would claim a
|
|
// measurement that never happened for the unparseable-file case below. The
|
|
// produced FILE overrides it below whenever it parses.
|
|
s.trackGuids = req.trackGuids;
|
|
s.channelCount = 0;
|
|
|
|
// 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
|
|
|
|
// 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;
|
|
TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
|
s.captureTimeSigNum = tsNum;
|
|
s.captureTimeSigDenom = tsDenom;
|
|
}
|
|
|
|
// 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()) {
|
|
s.contentHash = hashWavContent(fileBytes);
|
|
// The one authority for the entry's channel count is the file's own `fmt`
|
|
// — never the render request, which asks for 2 on every capture path.
|
|
const WavLayout layout = parseWavLayout(fileBytes);
|
|
if (layout.valid) s.channelCount = static_cast<int>(layout.channelCount);
|
|
}
|
|
}
|
|
|
|
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
|
}
|
|
|
|
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|
CaptureResult result;
|
|
|
|
// 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 realtime backend).";
|
|
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;
|
|
}
|
|
|
|
// idx -1 == the active project tab.
|
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
if (!proj) {
|
|
result.status = CaptureStatus::NoProject;
|
|
result.message = "No active project.";
|
|
return result;
|
|
}
|
|
|
|
// 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).
|
|
//
|
|
// Flow: read .rpp path; if empty, Main_SaveProject(proj, true) — true =
|
|
// forceSaveAsIn — prompts Save-As and (per SDK header) blocks until dismissed,
|
|
// though that blocking behaviour cannot be verified from the header itself;
|
|
// re-read; if still empty (cancelled), refuse with NoProject and write nothing.
|
|
auto readRppPath = [&]() -> std::string {
|
|
std::vector<char> buf(4096, '\0');
|
|
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
|
return std::string(buf.data());
|
|
};
|
|
|
|
std::string rppPath = readRppPath();
|
|
if (rppPath.empty()) {
|
|
Main_SaveProject(proj, true);
|
|
rppPath = readRppPath();
|
|
}
|
|
if (rppPath.empty()) {
|
|
result.status = CaptureStatus::NoProject;
|
|
result.message = "Project must be saved before capture — nothing captured.";
|
|
return result;
|
|
}
|
|
|
|
// 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();
|
|
for (char& c : dir) { if (c == '\\') c = '/'; }
|
|
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
|
|
return dir;
|
|
}();
|
|
|
|
// 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);
|
|
|
|
ScopedRenderSettings guard(proj);
|
|
|
|
// 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);
|
|
|
|
// 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 (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);
|
|
|
|
// 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));
|
|
|
|
// 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);
|
|
}
|
|
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
|
static_cast<double>(request.channelCount), true);
|
|
|
|
// Load-bearing: never add the rendered file to the project as an item.
|
|
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
|
|
|
|
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
|
|
|
|
// 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);
|
|
|
|
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
|
|
// format extension. paths.fileStem already owns the .wav suffix knowledge.
|
|
setProjString(proj, "RENDER_FILE", paths.absoluteDir);
|
|
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
|
|
|
|
// 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 "
|
|
"(Float32 only; Int16/Int24 not yet supported).";
|
|
return result;
|
|
}
|
|
setProjString(proj, "RENDER_FORMAT", fmtBase64);
|
|
|
|
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Exact bounds, made structural: with no tail requested the file must contain
|
|
// (within a tolerance, see below) the requested window's frames, so a source
|
|
// mode that silently widened the render fails loudly here instead of landing as
|
|
// a successful capture. Auto and Manual add frames by design and are skipped.
|
|
// (On TailMode::None the landed file is read three times on this path — this gate,
|
|
// the mono collapse, and stampCaptureSample — plus one rewrite when the collapse
|
|
// fires; Auto/Manual skip this gate entirely, so they read it twice. A
|
|
// once-per-capture cost on an already-warm file, judged acceptable.) A
|
|
// bounded/header-only read is not a clean substitute: parseWavLayout only marks the
|
|
// data chunk valid when the buffer holds the chunk's FULL declared body
|
|
// (bodyInBounds), so a truncated read would read as invalid here on every real
|
|
// capture, not just malformed ones.
|
|
if (request.tailMode == TailMode::None) {
|
|
const WavLayout layout =
|
|
parseWavLayout(util::readFileBytes(expectedPath));
|
|
const long long expectedFrames = layout.valid
|
|
? frameCountFor(request.startSeconds, request.endSeconds,
|
|
static_cast<int>(layout.sampleRate))
|
|
: 0;
|
|
const long long actualFrames = static_cast<long long>(layout.frameCount());
|
|
// frameCountFor is a difference of frame indices, not a rounded duration
|
|
// (see render_window.h) — REAPER's own edge-rounding can legitimately land
|
|
// one frame off that, so the gate tolerates +/-1 rather than exact equality.
|
|
// The defect this refuses is a whole-item widening (seconds of extra audio,
|
|
// thousands of frames), which a 1-frame tolerance still catches with
|
|
// certainty. Tightening to exact equality needs a DAW pass confirming REAPER
|
|
// resolves the window's two edges to frame indices the same way this does.
|
|
const long long frameDelta = actualFrames > expectedFrames
|
|
? actualFrames - expectedFrames
|
|
: expectedFrames - actualFrames;
|
|
if (expectedFrames > 0 && frameDelta > 1) {
|
|
result.status = CaptureStatus::BoundsMismatch;
|
|
result.message = "Render produced " + std::to_string(actualFrames) +
|
|
" frames but the requested range is " +
|
|
std::to_string(expectedFrames) + " at " +
|
|
std::to_string(layout.sampleRate) +
|
|
" Hz -- the render did not honor the requested bounds. "
|
|
"Requested [" + std::to_string(request.startSeconds) +
|
|
"s, " + std::to_string(request.endSeconds) +
|
|
"s) -> frame indices [" +
|
|
std::to_string(std::llround(request.startSeconds *
|
|
layout.sampleRate)) +
|
|
", " +
|
|
std::to_string(std::llround(request.endSeconds *
|
|
layout.sampleRate)) +
|
|
"). Nothing was added to the bank; the render at " +
|
|
expectedPath + " was never indexed and has been cleaned up.";
|
|
std::error_code ec;
|
|
std::filesystem::remove(expectedPath, ec);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures
|
|
// REAPER's own render against the requested window, so nothing of ours may sit
|
|
// between the render and that measurement, and a refusal must delete the
|
|
// renderer's file rather than one this step had already rewritten. The collapse
|
|
// preserves the frame count, so the two are order-independent in outcome — only
|
|
// in what each is measuring.
|
|
const MonoCollapseOutcome collapseOutcome =
|
|
collapseCapturedFileToMono(expectedPath);
|
|
|
|
// Record the request's own bounds (exact) rather than re-measuring the file.
|
|
Sample s;
|
|
// 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.label();
|
|
s.relativePath = paths.relativePath; // project-relative (invariant)
|
|
s.sourceMode = request.sourceMode;
|
|
s.sourceRange.startSeconds = request.startSeconds;
|
|
s.sourceRange.endSeconds = request.endSeconds;
|
|
// 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;
|
|
stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath);
|
|
// 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;
|
|
result.message = "Captured [" +
|
|
std::to_string(request.startSeconds) + "s, " +
|
|
std::to_string(request.endSeconds) + "s] -> " +
|
|
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
|
return result;
|
|
}
|
|
|
|
} // namespace reasampler::capture
|