diff --git a/CMakeLists.txt b/CMakeLists.txt index c943c62..46e1053 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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") diff --git a/src/capture.cpp b/src/capture.cpp new file mode 100644 index 0000000..03c66af --- /dev/null +++ b/src/capture.cpp @@ -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 +#include +#include +#include +#include +#include + +#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(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& out, std::int32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((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 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 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 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 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(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 projPathBuf(4096, '\0'); + GetProjectPathEx(proj, projPathBuf.data(), static_cast(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(request.sampleRate), true); + GetSetProjectInfo(proj, "RENDER_CHANNELS", + static_cast(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 fmt = wavSinkConfigPinned(request.bitDepth); + { + std::vector 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::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 diff --git a/src/capture.h b/src/capture.h new file mode 100644 index 0000000..c90b7a9 --- /dev/null +++ b/src/capture.h @@ -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 + +#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 diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp new file mode 100644 index 0000000..9f1e3b8 --- /dev/null +++ b/src/capture_paths.cpp @@ -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(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 diff --git a/src/capture_paths.h b/src/capture_paths.h new file mode 100644 index 0000000..c9210ac --- /dev/null +++ b/src/capture_paths.h @@ -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 + +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; // /reasampler_bank (forward slash) + std::string relativePath; // reasampler_bank/ (index value) + std::string fileName; // .wav (full file name) + std::string fileStem; // (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 "[_].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 diff --git a/src/main.cpp b/src/main.cpp index 0ed5d46..6476040 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,11 @@ #include "reaper_plugin.h" #include "reaper_plugin_functions.h" +#include + +#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 diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp new file mode 100644 index 0000000..6c96dcd --- /dev/null +++ b/tests/test_capture_paths.cpp @@ -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 +#include + +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; +}