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:
+14
-1
@@ -28,6 +28,14 @@ target_include_directories(bank_model PUBLIC src)
|
||||
add_library(peaks STATIC src/peaks.cpp)
|
||||
target_include_directories(peaks PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2b) Pure capture-path arithmetic — NO REAPER, NO SWELL. Bank-folder / unique
|
||||
# file-name / project-relative path derivation for the capture shell (M3).
|
||||
# Split out so the fiddly path logic is unit-tested outside the DAW.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(capture_paths STATIC src/capture_paths.cpp)
|
||||
target_include_directories(capture_paths PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -40,13 +48,18 @@ add_executable(peaks_tests tests/test_peaks.cpp)
|
||||
target_link_libraries(peaks_tests PRIVATE peaks)
|
||||
add_test(NAME peaks_tests COMMAND peaks_tests)
|
||||
|
||||
add_executable(capture_paths_tests tests/test_capture_paths.cpp)
|
||||
target_link_libraries(capture_paths_tests PRIVATE capture_paths)
|
||||
add_test(NAME capture_paths_tests COMMAND capture_paths_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(reaper_reasampler MODULE
|
||||
src/main.cpp
|
||||
src/capture.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||
|
||||
|
||||
+397
@@ -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
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
|
||||
//
|
||||
// This header declares the capture *seam* the later milestones fill:
|
||||
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
|
||||
// * ICaptureBackend — the one interface behind which OfflineRenderBackend
|
||||
// (M3, here) and RealtimeRecordBackend (M8) both sit.
|
||||
// * OfflineRenderBackend — the deterministic default; M3 implements ONLY the
|
||||
// time-selection master-mix case.
|
||||
//
|
||||
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
|
||||
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
|
||||
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
|
||||
// without dragging the SDK into every include site.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "bank_model.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
|
||||
// rationale lives in capture.cpp next to the sink-config bytes.
|
||||
enum class WavBitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Float32,
|
||||
};
|
||||
|
||||
// One capture, independent of source mode. Populated by the caller (the action
|
||||
// handler in M3; the action family in M7) and consumed by a backend.
|
||||
//
|
||||
// M3 fills only the fields the master-mix/time-selection path needs; the rest
|
||||
// are declared now so M7/M8 do not reshape the struct (they are the seam).
|
||||
struct CaptureRequest {
|
||||
SourceMode sourceMode = SourceMode::MasterMix;
|
||||
|
||||
// Sample-accurate render bounds in project seconds. For the M3 spike these
|
||||
// come straight from the time selection (GetSet_LoopTimeRange) — NO rounding.
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
|
||||
// 1.0 = fully wet, 0.0 = fully dry. M3 renders the wet master mix (1.0);
|
||||
// dry/partial routing is M7. Carried now so the Sample records it.
|
||||
double wetDry = 1.0;
|
||||
|
||||
// Render tail. Default OFF for the spike (exact bounds, no added silence —
|
||||
// precision invariant). M7 makes this bindable.
|
||||
bool renderTail = false;
|
||||
double tailMs = 0.0;
|
||||
|
||||
// Output format. 0 sampleRate => follow project rate (deterministic: the
|
||||
// project rate is fixed for a given project).
|
||||
int sampleRate = 0;
|
||||
int channelCount = 2;
|
||||
WavBitDepth bitDepth = WavBitDepth::Float32;
|
||||
|
||||
// Human base name for the file stem; sanitized by capture_paths. The unique
|
||||
// tag (disambiguator) is supplied separately by the backend caller so the
|
||||
// pure naming logic stays testable.
|
||||
std::string baseName = "capture";
|
||||
std::string uniqueTag; // e.g. a timestamp/counter; may be empty
|
||||
};
|
||||
|
||||
// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure
|
||||
// is an explicit code (never a thrown exception across the REAPER boundary) so
|
||||
// the action handler can log a precise reason.
|
||||
enum class CaptureStatus {
|
||||
Ok,
|
||||
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)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
};
|
||||
|
||||
struct CaptureResult {
|
||||
CaptureStatus status = CaptureStatus::RenderFailed;
|
||||
Sample sample; // valid only when status == Ok
|
||||
std::string message; // human-readable detail for the console log
|
||||
};
|
||||
|
||||
// The capture seam. One method: run a request, return a populated Sample (or a
|
||||
// failure code). Backends are non-destructive — they must restore any global
|
||||
// state they touch before returning (OfflineRenderBackend snapshots/restores the
|
||||
// RENDER_* project settings).
|
||||
class ICaptureBackend {
|
||||
public:
|
||||
virtual ~ICaptureBackend() = default;
|
||||
virtual CaptureResult capture(const CaptureRequest& request) = 0;
|
||||
};
|
||||
|
||||
// Deterministic offline-render backend. M3 implements ONLY the
|
||||
// TimeSelection / MasterMix case (both map to "render the master mix over the
|
||||
// requested bounds"); any other source mode returns UnsupportedMode.
|
||||
class OfflineRenderBackend : public ICaptureBackend {
|
||||
public:
|
||||
CaptureResult capture(const CaptureRequest& request) override;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "capture_paths.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string normalizeSlashes(const std::string& path) {
|
||||
std::string out = path;
|
||||
for (char& c : out) {
|
||||
if (c == '\\') c = '/';
|
||||
}
|
||||
// Strip a single trailing slash so joins do not double up. Preserve a lone
|
||||
// "/" (root) — stripping it would turn root into empty.
|
||||
if (out.size() > 1 && out.back() == '/') {
|
||||
out.pop_back();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string sanitizeStem(const std::string& baseName) {
|
||||
std::string out;
|
||||
out.reserve(baseName.size());
|
||||
for (unsigned char c : baseName) {
|
||||
const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
|
||||
c == '-';
|
||||
out.push_back(keep ? static_cast<char>(c) : '_');
|
||||
}
|
||||
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
|
||||
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
|
||||
bool hasAlnum = false;
|
||||
for (unsigned char c : out) {
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9')) {
|
||||
hasAlnum = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (out.empty() || !hasAlnum) {
|
||||
return "capture";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||
const std::string& baseName,
|
||||
const std::string& uniqueTag) {
|
||||
const std::string dir = normalizeSlashes(projectDir);
|
||||
|
||||
std::string stem = sanitizeStem(baseName);
|
||||
if (!uniqueTag.empty()) {
|
||||
stem += "_" + sanitizeStem(uniqueTag);
|
||||
}
|
||||
const std::string fileName = stem + ".wav";
|
||||
|
||||
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)
|
||||
: dir + "/" + kBankSubfolder;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
// capture_paths — the REAPER-free path arithmetic behind offline capture.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The capture shell resolves the
|
||||
// current project directory via REAPER APIs, then hands the raw strings here so
|
||||
// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file
|
||||
// name, absolute render dir, project-relative index path) is unit-tested outside
|
||||
// the DAW.
|
||||
//
|
||||
// Path convention: this module works in forward-slash form and does NOT touch
|
||||
// the filesystem. The bank subfolder name is a fixed constant so the same
|
||||
// project always resolves the same bank location (determinism).
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The project-relative bank subfolder. All captured wavs live here so the bank
|
||||
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank).
|
||||
inline constexpr const char* kBankSubfolder = "reasampler_bank";
|
||||
|
||||
// A resolved pair of paths for one capture: where REAPER must be told to write
|
||||
// (absolute, because RENDER_FILE wants a directory REAPER can create/open) and
|
||||
// what we store in the BankIndex (project-relative, because the index is
|
||||
// relative-paths-only — CLAUDE.md precision invariant).
|
||||
struct BankPaths {
|
||||
std::string absoluteDir; // <projectDir>/reasampler_bank (forward slash)
|
||||
std::string relativePath; // reasampler_bank/<fileName> (index value)
|
||||
std::string fileName; // <stem>.wav (full file name)
|
||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||
};
|
||||
|
||||
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
|
||||
// -> empty out. Pure string transform (does not consult the filesystem).
|
||||
std::string normalizeSlashes(const std::string& path);
|
||||
|
||||
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
|
||||
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control)
|
||||
// with '_', and collapses to "capture" if nothing usable remains. Deterministic:
|
||||
// the same input always yields the same stem (feeds bit-identical file naming).
|
||||
std::string sanitizeStem(const std::string& baseName);
|
||||
|
||||
// Derives the bank paths for one capture.
|
||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
||||
// baseName : human base for the file stem (sanitized)
|
||||
// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a
|
||||
// timestamp or counter) so repeated captures do not collide.
|
||||
// Also sanitized. May be empty.
|
||||
// Produces "<stem>[_<tag>].wav". The relativePath is always project-relative and
|
||||
// forward-slashed so it satisfies BankIndex::add's relative-only invariant.
|
||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||
const std::string& baseName,
|
||||
const std::string& uniqueTag);
|
||||
|
||||
} // namespace reasampler
|
||||
+87
-25
@@ -18,6 +18,11 @@
|
||||
#include "reaper_plugin.h"
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "bank_model.h"
|
||||
#include "capture.h"
|
||||
|
||||
// Persistent action-id prefix for the ReaSampler action family.
|
||||
// Every bindable action (capture / insert / slot / verify) mints its command id
|
||||
// from a string beginning with this prefix, e.g. "CEREBELLUM_REASAMPLER_CAPTURE_MASTER".
|
||||
@@ -29,30 +34,68 @@
|
||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
|
||||
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
|
||||
|
||||
// ---- Action registration seam (reusable pattern, no action wired yet) ------
|
||||
// The capture/insert/slot action family (PLAN.md M3+) reinstates the pattern
|
||||
// below. Kept as a commented template so the shape is not re-derived each time.
|
||||
//
|
||||
// static int g_cmdCaptureMaster = 0;
|
||||
//
|
||||
// // REAPER calls this for EVERY action fired anywhere; claim only your own id,
|
||||
// // return false otherwise so REAPER keeps looking.
|
||||
// static bool OnHookCommand(int command, int /*flag*/)
|
||||
// {
|
||||
// if (command == 0) return false;
|
||||
// if (command == g_cmdCaptureMaster) { /* route to capture module */ return true; }
|
||||
// return false;
|
||||
// }
|
||||
//
|
||||
// Registration, inside the load branch below:
|
||||
// 1) g_cmdCaptureMaster =
|
||||
// rec->Register("command_id", (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER"));
|
||||
// 2) static gaccel_register_t sAccel{};
|
||||
// sAccel.accel.cmd = g_cmdCaptureMaster;
|
||||
// sAccel.desc = "ReaSampler: capture master mix";
|
||||
// rec->Register("gaccel", (void*)&sAccel);
|
||||
// 3) rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
// On unload (rec == nullptr), mirror-unregister with the same strings prefixed '-'.
|
||||
// ---- Action registration seam (M3 spike: capture master mix) ---------------
|
||||
// First live use of the action pattern the seam left templated. The full capture
|
||||
// action family (selected tracks/items/razor, wet/dry, tail) is M7; this is ONE
|
||||
// temporary action driving the M3 offline-render spike.
|
||||
|
||||
// Command id for "ReaSampler: capture master mix (spike)". FOREVER-STABLE string
|
||||
// (user keybindings key off it) — see the prefix note above.
|
||||
static int g_cmdCaptureMasterSpike = 0;
|
||||
|
||||
// In-memory bank for the spike. M4 replaces this with project ext-state persist;
|
||||
// for M3 the index lives only for the session, proving the capture->Sample->add
|
||||
// path end to end.
|
||||
static reasampler::BankIndex g_bank;
|
||||
|
||||
// Runs the M3 spike: render the time-selection master mix, add the Sample, log.
|
||||
static void RunCaptureMasterSpike()
|
||||
{
|
||||
// Time selection -> exact render bounds (no rounding). GetSet_LoopTimeRange
|
||||
// with isSet=false reads the current time selection (isLoop=false).
|
||||
double start = 0.0, end = 0.0;
|
||||
GetSet_LoopTimeRange(false, false, &start, &end, false);
|
||||
|
||||
reasampler::CaptureRequest req;
|
||||
req.sourceMode = reasampler::SourceMode::TimeSelection;
|
||||
req.startSeconds = start;
|
||||
req.endSeconds = end;
|
||||
req.wetDry = 1.0; // wet master mix
|
||||
req.renderTail = false; // exact bounds, no tail
|
||||
req.sampleRate = 0; // follow project rate
|
||||
req.channelCount = 2;
|
||||
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
|
||||
req.baseName = "master_mix";
|
||||
|
||||
reasampler::OfflineRenderBackend backend;
|
||||
reasampler::CaptureResult res = backend.capture(req);
|
||||
|
||||
if (res.status != reasampler::CaptureStatus::Ok)
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
reasampler::AddResult added = g_bank.add(res.sample);
|
||||
std::string log = "ReaSampler: " + res.message + "\n";
|
||||
log += " bank size now " + std::to_string(g_bank.size()) +
|
||||
(added == reasampler::AddResult::Added ? " (added)\n"
|
||||
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
|
||||
: " (rejected)\n");
|
||||
ShowConsoleMsg(log.c_str());
|
||||
}
|
||||
|
||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
|
||||
// return false otherwise so REAPER keeps looking.
|
||||
static bool OnHookCommand(int command, int /*flag*/)
|
||||
{
|
||||
if (command == 0) return false;
|
||||
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// gaccel storage must outlive registration — REAPER holds the pointer.
|
||||
static gaccel_register_t g_accelCaptureMaster{};
|
||||
|
||||
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
|
||||
@@ -60,7 +103,14 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
if (!rec)
|
||||
{
|
||||
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
|
||||
// callback here (same strings prefixed with '-') once actions are wired.
|
||||
// callback with the same strings prefixed '-' (per the contract).
|
||||
if (g_rec)
|
||||
{
|
||||
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster);
|
||||
g_rec->Register("-command_id",
|
||||
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE"));
|
||||
}
|
||||
g_rec = nullptr;
|
||||
return 0;
|
||||
}
|
||||
@@ -77,6 +127,18 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
g_hInst = hInstance;
|
||||
g_rec = rec;
|
||||
|
||||
// Register the M3 spike action (command_id -> gaccel -> hookcommand).
|
||||
g_cmdCaptureMasterSpike = rec->Register(
|
||||
"command_id",
|
||||
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE"));
|
||||
if (g_cmdCaptureMasterSpike)
|
||||
{
|
||||
g_accelCaptureMaster.accel.cmd = g_cmdCaptureMasterSpike;
|
||||
g_accelCaptureMaster.desc = "ReaSampler: capture master mix (spike)";
|
||||
rec->Register("gaccel", (void*)&g_accelCaptureMaster);
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
}
|
||||
|
||||
ShowConsoleMsg("ReaSampler loaded.\n");
|
||||
|
||||
return 1; // success — REAPER keeps us loaded
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Standalone tests for reasampler::capture_paths — no REAPER, no framework.
|
||||
// The capture shell is DAW-bound and only verifiable in REAPER; this covers the
|
||||
// one genuinely pure piece: the bank-folder / unique-name / project-relative
|
||||
// path arithmetic that feeds BankIndex::add's relative-only invariant.
|
||||
|
||||
#include "../src/capture_paths.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static void testNormalizeSlashes() {
|
||||
CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b");
|
||||
CHECK(normalizeSlashes("a/b/c") == "a/b/c");
|
||||
CHECK(normalizeSlashes("a/b/") == "a/b"); // trailing slash stripped
|
||||
CHECK(normalizeSlashes("a\\b\\") == "a/b"); // backslash + trailing
|
||||
CHECK(normalizeSlashes("/") == "/"); // lone root preserved
|
||||
CHECK(normalizeSlashes("") == ""); // empty stays empty
|
||||
}
|
||||
|
||||
static void testSanitizeStem() {
|
||||
// Safe characters survive verbatim.
|
||||
CHECK(sanitizeStem("Kick_01.take-2") == "Kick_01.take-2");
|
||||
// Spaces, slashes, quotes, control chars become '_'.
|
||||
CHECK(sanitizeStem("my mix") == "my_mix");
|
||||
CHECK(sanitizeStem("a/b\\c") == "a_b_c");
|
||||
CHECK(sanitizeStem("q\"uote") == "q_uote");
|
||||
CHECK(sanitizeStem(std::string("nul\0byte", 8)) == "nul_byte");
|
||||
// Nothing usable -> stable default.
|
||||
CHECK(sanitizeStem("") == "capture");
|
||||
CHECK(sanitizeStem(" ") == "capture");
|
||||
// All-separator (no alphanumeric) -> default, so the name is meaningful.
|
||||
CHECK(sanitizeStem("...") == "capture");
|
||||
CHECK(sanitizeStem("-_-") == "capture");
|
||||
}
|
||||
|
||||
static void testDeriveRelativePathIsProjectRelative() {
|
||||
BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "master mix", "1753080000");
|
||||
// Relative path is under the fixed bank subfolder, forward-slashed, .wav.
|
||||
CHECK(p.relativePath == "reasampler_bank/master_mix_1753080000.wav");
|
||||
// It must NOT be absolute by any of BankIndex::add's rejection rules:
|
||||
// no leading '/', no drive letter, no backslash, no UNC prefix.
|
||||
CHECK(p.relativePath.find(':') == std::string::npos);
|
||||
CHECK(p.relativePath.find('\\') == std::string::npos);
|
||||
CHECK(!p.relativePath.empty() && p.relativePath[0] != '/');
|
||||
CHECK(p.relativePath.rfind("\\\\", 0) != 0);
|
||||
}
|
||||
|
||||
static void testDeriveAbsoluteDirJoinsProjectDir() {
|
||||
BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "kick", "");
|
||||
// Backslashes normalized; bank subfolder appended; no trailing slash.
|
||||
CHECK(p.absoluteDir == "C:/Users/d/proj/reasampler_bank");
|
||||
// No unique tag -> stem has no trailing "_".
|
||||
CHECK(p.fileName == "kick.wav");
|
||||
CHECK(p.relativePath == "reasampler_bank/kick.wav");
|
||||
}
|
||||
|
||||
static void testDeriveHandlesTrailingSlashProjectDir() {
|
||||
// A project dir with a trailing slash must not double up in the join.
|
||||
BankPaths p = deriveBankPaths("/home/d/proj/", "mix", "7");
|
||||
CHECK(p.absoluteDir == "/home/d/proj/reasampler_bank");
|
||||
CHECK(p.fileName == "mix_7.wav");
|
||||
}
|
||||
|
||||
static void testDeriveEmptyProjectDirFallsBackToRelative() {
|
||||
// Defensive: with no project dir, absoluteDir is just the bank subfolder
|
||||
// (the shell rejects the no-path case before this, but the arithmetic must
|
||||
// not emit a leading slash that would read as absolute).
|
||||
BankPaths p = deriveBankPaths("", "mix", "");
|
||||
CHECK(p.absoluteDir == "reasampler_bank");
|
||||
CHECK(p.relativePath == "reasampler_bank/mix.wav");
|
||||
}
|
||||
|
||||
static void testDeterministicForSameInputs() {
|
||||
// Same inputs -> same derived paths (feeds deterministic file naming).
|
||||
BankPaths a = deriveBankPaths("C:/p", "mix", "42");
|
||||
BankPaths b = deriveBankPaths("C:/p", "mix", "42");
|
||||
CHECK(a.absoluteDir == b.absoluteDir);
|
||||
CHECK(a.relativePath == b.relativePath);
|
||||
CHECK(a.fileName == b.fileName);
|
||||
}
|
||||
|
||||
static void testFileStem() {
|
||||
// fileStem is the stem component of fileName (no extension). The capture
|
||||
// backend passes fileStem directly to RENDER_PATTERN because REAPER appends
|
||||
// the format extension itself — the backend must not re-derive or re-strip it.
|
||||
BankPaths p = deriveBankPaths("C:/p", "master mix", "123");
|
||||
CHECK(p.fileStem == "master_mix_123");
|
||||
CHECK(p.fileName == "master_mix_123.wav");
|
||||
// fileStem + ".wav" must equal fileName (the invariant the backend relies on).
|
||||
CHECK(p.fileStem + ".wav" == p.fileName);
|
||||
|
||||
// No tag: stem only.
|
||||
BankPaths q = deriveBankPaths("C:/p", "kick", "");
|
||||
CHECK(q.fileStem == "kick");
|
||||
CHECK(q.fileName == "kick.wav");
|
||||
CHECK(q.fileStem + ".wav" == q.fileName);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNormalizeSlashes();
|
||||
testSanitizeStem();
|
||||
testDeriveRelativePathIsProjectRelative();
|
||||
testDeriveAbsoluteDirJoinsProjectDir();
|
||||
testDeriveHandlesTrailingSlashProjectDir();
|
||||
testDeriveEmptyProjectDirFallsBackToRelative();
|
||||
testDeterministicForSameInputs();
|
||||
testFileStem();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
|
||||
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user