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:
2026-07-22 13:07:31 -04:00
parent 93e2783098
commit d9ad8e4adf
7 changed files with 837 additions and 26 deletions
+118
View File
@@ -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;
}