Fix M3 capture: render format, project rate, unsaved-project handling
RENDER_FORMAT now uses the base64 float-WAV config (raw bytes were rejected into a 16-bit/44.1 fallback); RENDER_SRATE pinned to the project rate. Unsaved projects prompt Save then refuse if cancelled — no default-location fallback. Harden deriveBankPaths against empty projectDir.
This commit is contained in:
+88
-56
@@ -13,7 +13,6 @@
|
||||
#include "capture.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
@@ -28,6 +27,7 @@
|
||||
#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"
|
||||
@@ -81,45 +81,30 @@ constexpr double kNormalizeDisableAll = static_cast<double>(4 << 16); // 262144
|
||||
// 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).
|
||||
// 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.
|
||||
//
|
||||
// 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));
|
||||
}
|
||||
// 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==";
|
||||
|
||||
// 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
|
||||
// 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::Int16: depthCode = 1; break;
|
||||
case WavBitDepth::Int24: depthCode = 2; break;
|
||||
case WavBitDepth::Float32: depthCode = 4; break;
|
||||
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
|
||||
}
|
||||
std::vector<char> cfg = {'e', 'v', 'a', 'w'};
|
||||
appendInt32LE(cfg, depthCode);
|
||||
appendInt32LE(cfg, 0); // flags: defaults (LE, no metadata)
|
||||
return cfg;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// --- RENDER_* snapshot / restore --------------------------------------------
|
||||
@@ -260,12 +245,38 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// 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());
|
||||
//
|
||||
// Unsaved-project guard: an active project that has never been saved has
|
||||
// an empty path. In that state any bank folder resolution would either fall
|
||||
// back to a CWD-relative directory (violating the relative-paths-only
|
||||
// invariant) or write to REAPER's default media location — both are wrong.
|
||||
// Instead we prompt the user to save, then re-query. If the save dialog is
|
||||
// cancelled (path still empty), refuse and write nothing.
|
||||
//
|
||||
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
|
||||
// dialog and blocks until the user dismisses it. "true" means 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 resolveProjectDir = [&]() -> std::string {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetProjectPathEx(proj, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
};
|
||||
|
||||
std::string projectDir = resolveProjectDir();
|
||||
if (projectDir.empty()) {
|
||||
// Prompt the user to save so the project acquires a path.
|
||||
Main_SaveProject(proj, true);
|
||||
// Re-query: if the dialog was confirmed the path is now set; if the
|
||||
// user cancelled it is still empty.
|
||||
projectDir = resolveProjectDir();
|
||||
}
|
||||
if (projectDir.empty()) {
|
||||
// User cancelled the save dialog — refuse, write nothing.
|
||||
result.status = CaptureStatus::NoProject;
|
||||
result.message = "Project has no path yet (save the project first).";
|
||||
result.message = "Project must be saved before capture — nothing captured.";
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -297,10 +308,26 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
@@ -323,14 +350,18 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
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);
|
||||
// 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 headless render ----------------------------------------
|
||||
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
|
||||
@@ -371,11 +402,12 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -70,6 +70,7 @@ enum class CaptureStatus {
|
||||
NoProject, // no active project to render / resolve a bank folder
|
||||
EmptyRange, // start >= end: nothing to render
|
||||
UnsupportedMode, // backend does not implement this source mode (M3 scope)
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
};
|
||||
|
||||
|
||||
+12
-1
@@ -1,5 +1,7 @@
|
||||
#include "capture_paths.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string normalizeSlashes(const std::string& path) {
|
||||
@@ -51,13 +53,22 @@ BankPaths deriveBankPaths(const std::string& projectDir,
|
||||
}
|
||||
const std::string fileName = stem + ".wav";
|
||||
|
||||
// Precondition: the capture shell must resolve a non-empty project directory
|
||||
// before calling this function. An empty projectDir would produce a bare
|
||||
// relative "reasampler_bank" path — the silent default-location fallback this
|
||||
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
|
||||
// so any caller that ignores the precondition fails loudly at the render/stat
|
||||
// step rather than silently writing to CWD.
|
||||
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
||||
|
||||
BankPaths p;
|
||||
p.fileStem = stem; // stem only — REAPER appends extension
|
||||
p.fileName = fileName;
|
||||
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
||||
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
|
||||
// directory itself; RENDER_PATTERN supplies the file name separately).
|
||||
p.absoluteDir = dir.empty() ? std::string(kBankSubfolder)
|
||||
// Empty when precondition is violated (dir empty) — caller must not proceed.
|
||||
p.absoluteDir = dir.empty() ? std::string{}
|
||||
: dir + "/" + kBankSubfolder;
|
||||
return p;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user