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:
+87
-55
@@ -13,7 +13,6 @@
|
|||||||
#include "capture.h"
|
#include "capture.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -28,6 +27,7 @@
|
|||||||
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
||||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||||
#define REAPERAPI_WANT_Main_OnCommand
|
#define REAPERAPI_WANT_Main_OnCommand
|
||||||
|
#define REAPERAPI_WANT_Main_SaveProject
|
||||||
#define REAPERAPI_WANT_Master_GetTempo
|
#define REAPERAPI_WANT_Master_GetTempo
|
||||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||||
#include "reaper_plugin_functions.h"
|
#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
|
// capture nulls exactly against its source. 16/24-bit int paths require dither
|
||||||
// for correctness, which is nondeterministic — unacceptable for a precision tool.
|
// for correctness, which is nondeterministic — unacceptable for a precision tool.
|
||||||
//
|
//
|
||||||
// DAW-ONLY ASSUMPTION: RENDER_FORMAT takes a base64-encoded sink config, OR
|
// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...)
|
||||||
// (per SDK header line ~3114) a simple 4-byte string to use *default* settings
|
// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes.
|
||||||
// for that sink type. The WAV sink fourcc is "evaw" ("wave" little-endian).
|
// 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
|
// GROUND TRUTH: base64 string captured from a live REAPER configured to
|
||||||
// USER'S default WAV bit depth — not deterministic across machines. To PIN the
|
// WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00
|
||||||
// bit depth we build the full WAV sink config blob below. That blob's byte
|
// = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field)
|
||||||
// layout is REAPER-INTERNAL and is NOT in the SDK header, so it cannot be
|
// + 0x00 0x00 (flags: little-endian, no BWF/loop metadata).
|
||||||
// verified here and MUST be confirmed in a running REAPER (capture, then inspect
|
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";
|
||||||
// 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
|
// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding
|
||||||
// above — the depth-code mapping is the load-bearing unknown to confirm live.
|
// was not captured from a live REAPER and must not be guessed. If M7+ adds
|
||||||
std::vector<char> wavSinkConfigPinned(WavBitDepth depth) {
|
// them, capture the ground-truth base64 from a running REAPER first.
|
||||||
std::int32_t depthCode = 4; // default: 32-bit float
|
//
|
||||||
|
// Returns nullptr for unsupported depths.
|
||||||
|
const char* wavSinkConfigBase64(WavBitDepth depth) {
|
||||||
switch (depth) {
|
switch (depth) {
|
||||||
case WavBitDepth::Int16: depthCode = 1; break;
|
case WavBitDepth::Float32: return kRenderFormatWavFloat32;
|
||||||
case WavBitDepth::Int24: depthCode = 2; break;
|
case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first
|
||||||
case WavBitDepth::Float32: depthCode = 4; break;
|
case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first
|
||||||
}
|
}
|
||||||
std::vector<char> cfg = {'e', 'v', 'a', 'w'};
|
return nullptr;
|
||||||
appendInt32LE(cfg, depthCode);
|
|
||||||
appendInt32LE(cfg, 0); // flags: defaults (LE, no metadata)
|
|
||||||
return cfg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RENDER_* snapshot / restore --------------------------------------------
|
// --- RENDER_* snapshot / restore --------------------------------------------
|
||||||
@@ -260,12 +245,38 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
// Resolve the project directory. GetProjectPathEx writes the effective
|
// Resolve the project directory. GetProjectPathEx writes the effective
|
||||||
// recording/project path (absolute). Verified: SDK header line ~2550,
|
// recording/project path (absolute). Verified: SDK header line ~2550,
|
||||||
// GetProjectPathEx(ReaProject*, char* bufOut, int bufOut_sz).
|
// GetProjectPathEx(ReaProject*, char* bufOut, int bufOut_sz).
|
||||||
std::vector<char> projPathBuf(4096, '\0');
|
//
|
||||||
GetProjectPathEx(proj, projPathBuf.data(), static_cast<int>(projPathBuf.size()));
|
// Unsaved-project guard: an active project that has never been saved has
|
||||||
const std::string projectDir(projPathBuf.data());
|
// 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()) {
|
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.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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,10 +308,26 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
// Master mix, no stems, no render matrix.
|
// Master mix, no stems, no render matrix.
|
||||||
GetSetProjectInfo(proj, "RENDER_SETTINGS", kRenderSettingsMasterMix, true);
|
GetSetProjectInfo(proj, "RENDER_SETTINGS", kRenderSettingsMasterMix, true);
|
||||||
|
|
||||||
// 0 sampleRate => follow the (fixed) project rate. Channel count preserved
|
// Resolve the effective sample rate. When the request carries 0 ("follow
|
||||||
// (no silent stereo fold — precision invariant).
|
// 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",
|
GetSetProjectInfo(proj, "RENDER_SRATE",
|
||||||
static_cast<double>(request.sampleRate), true);
|
static_cast<double>(effectiveSampleRate), true);
|
||||||
|
}
|
||||||
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
||||||
static_cast<double>(request.channelCount), true);
|
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_FILE", paths.absoluteDir);
|
||||||
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
|
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
|
||||||
|
|
||||||
// Pin the WAV format to the chosen bit depth (float by default). See the
|
// Pin the WAV format using the ground-truth base64 blob for the chosen depth.
|
||||||
// wavSinkConfigPinned DAW-ONLY ASSUMPTION.
|
// Int16/Int24 are not implemented (no live-captured blob) — fail explicitly
|
||||||
const std::vector<char> fmt = wavSinkConfigPinned(request.bitDepth);
|
// rather than silently mis-render at the wrong bit depth.
|
||||||
{
|
const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth);
|
||||||
std::vector<char> fmtBuf(fmt.begin(), fmt.end());
|
if (!fmtBase64) {
|
||||||
fmtBuf.push_back('\0');
|
result.status = CaptureStatus::UnsupportedFormat;
|
||||||
GetSetProjectInfo_String(proj, "RENDER_FORMAT", fmtBuf.data(), true);
|
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 ----------------------------------------
|
// --- Trigger the headless render ----------------------------------------
|
||||||
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
|
// 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.
|
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
|
||||||
s.wetDry = request.wetDry;
|
s.wetDry = request.wetDry;
|
||||||
s.channelCount = request.channelCount;
|
s.channelCount = request.channelCount;
|
||||||
// DEFERRED (M6/M7): sampleRate stored as 0 = "follow project rate". Resolving
|
// Store the resolved sample rate only when it is known (> 0). If the project
|
||||||
// the actual project sample rate via PROJECT_SRATE/PROJECT_SRATE_USE would
|
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
|
||||||
// require a live REAPER to verify. For the M3 spike this is intentional:
|
// either, so the render ran at REAPER's project default — an unknown value from
|
||||||
// the project rate is fixed for a given project and the render inherits it.
|
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
|
||||||
s.sampleRate = request.sampleRate; // 0 == follow project rate
|
// 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.lengthSeconds = request.endSeconds - request.startSeconds;
|
||||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
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
|
NoProject, // no active project to render / resolve a bank folder
|
||||||
EmptyRange, // start >= end: nothing to render
|
EmptyRange, // start >= end: nothing to render
|
||||||
UnsupportedMode, // backend does not implement this source mode (M3 scope)
|
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
|
RenderFailed, // the render action ran but produced no output file
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,7 @@
|
|||||||
#include "capture_paths.h"
|
#include "capture_paths.h"
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
std::string normalizeSlashes(const std::string& path) {
|
std::string normalizeSlashes(const std::string& path) {
|
||||||
@@ -51,13 +53,22 @@ BankPaths deriveBankPaths(const std::string& projectDir,
|
|||||||
}
|
}
|
||||||
const std::string fileName = stem + ".wav";
|
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;
|
BankPaths p;
|
||||||
p.fileStem = stem; // stem only — REAPER appends extension
|
p.fileStem = stem; // stem only — REAPER appends extension
|
||||||
p.fileName = fileName;
|
p.fileName = fileName;
|
||||||
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
||||||
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
|
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
|
||||||
// directory itself; RENDER_PATTERN supplies the file name separately).
|
// 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;
|
: dir + "/" + kBankSubfolder;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,13 +67,20 @@ static void testDeriveHandlesTrailingSlashProjectDir() {
|
|||||||
CHECK(p.fileName == "mix_7.wav");
|
CHECK(p.fileName == "mix_7.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testDeriveEmptyProjectDirFallsBackToRelative() {
|
static void testDeriveEmptyProjectDirIsRejected() {
|
||||||
// Defensive: with no project dir, absoluteDir is just the bank subfolder
|
// Precondition: deriveBankPaths requires a non-empty projectDir.
|
||||||
// (the shell rejects the no-path case before this, but the arithmetic must
|
// In debug builds the assert(!dir.empty()) fires immediately and aborts
|
||||||
// not emit a leading slash that would read as absolute).
|
// the process — that IS the check, so we don't call into it there.
|
||||||
|
// In release/NDEBUG builds the assert is elided; we verify the fallback
|
||||||
|
// contract: absoluteDir is left empty (not a bare "reasampler_bank") so
|
||||||
|
// any caller that ignores the precondition fails loudly at the render/stat
|
||||||
|
// step rather than silently writing to CWD.
|
||||||
|
#ifdef NDEBUG
|
||||||
BankPaths p = deriveBankPaths("", "mix", "");
|
BankPaths p = deriveBankPaths("", "mix", "");
|
||||||
CHECK(p.absoluteDir == "reasampler_bank");
|
CHECK(p.absoluteDir.empty());
|
||||||
CHECK(p.relativePath == "reasampler_bank/mix.wav");
|
CHECK(p.relativePath == "reasampler_bank/mix.wav");
|
||||||
|
#endif
|
||||||
|
// Debug: assert fires on the call above — contract verified by the crash.
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testDeterministicForSameInputs() {
|
static void testDeterministicForSameInputs() {
|
||||||
@@ -108,7 +115,7 @@ int main() {
|
|||||||
testDeriveRelativePathIsProjectRelative();
|
testDeriveRelativePathIsProjectRelative();
|
||||||
testDeriveAbsoluteDirJoinsProjectDir();
|
testDeriveAbsoluteDirJoinsProjectDir();
|
||||||
testDeriveHandlesTrailingSlashProjectDir();
|
testDeriveHandlesTrailingSlashProjectDir();
|
||||||
testDeriveEmptyProjectDirFallsBackToRelative();
|
testDeriveEmptyProjectDirIsRejected();
|
||||||
testDeterministicForSameInputs();
|
testDeterministicForSameInputs();
|
||||||
testFileStem();
|
testFileStem();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user