// 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 render, then // populates a Sample. It NEVER inserts into the arrange (load-bearing principle). // // RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API): // Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show // its offline-render progress dialog (progress bar + waveform view) for the // duration of the render. The RENDER_SETTINGS bits documented in // reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or // "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a // flag either. There is no stock, header-verifiable mechanism to prevent REAPER // from showing this UI for an offline file render triggered via Main_OnCommand. // This is inherent to REAPER's offline render path. The dialog-free alternative // is the realtime-record backend (M8), which captures the master bus output to a // temp track during playback and never invokes the offline render pipeline. #include "capture.h" #include #include #include #include #include #include "capture_paths.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetSetProjectInfo #define REAPERAPI_WANT_GetSetProjectInfo_String #define REAPERAPI_WANT_GetSet_LoopTimeRange #define REAPERAPI_WANT_Main_OnCommand #define REAPERAPI_WANT_Main_SaveProject #define REAPERAPI_WANT_Master_GetTempo #define REAPERAPI_WANT_ShowConsoleMsg #include "reaper_plugin_functions.h" 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. // // API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...) // uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes. // Writing raw bytes causes REAPER to silently reject the value and fall back to // the project's default render format (typically 16-bit/44.1 kHz). This was the // confirmed root cause of the M3 offline-capture regression. // // GROUND TRUTH: base64 string captured from a live REAPER configured to // WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00 // = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field) // + 0x00 0x00 (flags: little-endian, no BWF/loop metadata). constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA=="; // Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding // was not captured from a live REAPER and must not be guessed. If M7+ adds // them, capture the ground-truth base64 from a running REAPER first. // // Returns nullptr for unsupported depths. const char* wavSinkConfigBase64(WavBitDepth depth) { switch (depth) { case WavBitDepth::Float32: return kRenderFormatWavFloat32; case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first } return nullptr; } // --- 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 from the .rpp file path. // // Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read // the project's .rpp filename. Per SDK header line ~1262: // EnumProjects(int idx, char* projfnOutOptional, int sz) // "idx=-1 for current project, projfn can be NULL if not interested in filename." // The out-parameter is the full path to the .rpp file, and is EMPTY for a // project that has never been saved — making it a reliable unsaved sentinel. // // WHY NOT GetProjectPathEx: that function returns the project *recording path* // (SDK header line ~2548: "Get the project recording path."), NOT the .rpp // location. For an unsaved project it returns REAPER's default media/recording // directory — never empty — so it cannot detect the unsaved state. Using it // caused the original bug: the guard never fired, and captures landed in // REAPER's default media location rather than alongside the .rpp. // // WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project, // GetProjectPathEx returns the recording path (which may be a media subfolder), // not the .rpp parent directory. We need the .rpp parent so reasampler_bank/ // sits alongside the .rpp and travels with the project. // // FLOW: // 1. Read .rpp path via EnumProjects(-1, buf, bufsz). // 2. If non-empty (saved) -> derive project dir as parent of the .rpp. // 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As. // Re-read. If now non-empty -> proceed. If still empty (user cancelled) -> // refuse CaptureStatus::NoProject, write nothing. // // DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As // dialog and blocks until the user dismisses it. "true" = forceSaveAsIn. // Verified SDK header line ~4599: // void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional) // The blocking behaviour and dialog appearance can only be confirmed in a // running REAPER. auto readRppPath = [&]() -> std::string { std::vector buf(4096, '\0'); // EnumProjects(-1, ...) returns the active project and writes the .rpp // path into buf. We already have the ReaProject* from the earlier call // (nullptr-checked above), but calling EnumProjects again is the only // stock, header-documented way to read the .rpp filename. EnumProjects(-1, buf.data(), static_cast(buf.size())); return std::string(buf.data()); }; std::string rppPath = readRppPath(); if (rppPath.empty()) { // Project is unsaved. Prompt the user to choose a save location. Main_SaveProject(proj, true); // Re-read: non-empty if the user confirmed, still empty if cancelled. rppPath = readRppPath(); } if (rppPath.empty()) { // User cancelled the save dialog — refuse, write nothing. result.status = CaptureStatus::NoProject; result.message = "Project must be saved before capture — nothing captured."; return result; } // Derive the project directory as the parent folder of the .rpp file. // std::filesystem::path handles both forward- and back-slash paths; .parent_path() // gives the containing directory. Convert to forward-slash string so the rest // of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path. const std::string projectDir = [&]() -> std::string { namespace fs = std::filesystem; std::string dir = fs::path(rppPath).parent_path().string(); // normalizeSlashes is in capture_paths (pure); replicate the transform // inline here to avoid a cross-module dependency for a one-liner. for (char& c : dir) { if (c == '\\') c = '/'; } // Strip a single trailing slash (defensive; parent_path usually omits it). if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); return dir; }(); // 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); // Resolve the effective sample rate. When the request carries 0 ("follow // project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the // actual value — not left as 0 for REAPER to interpret. SDK header line ~3064: // PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the // value is still readable via GetSetProjectInfo even when _USE is clear. const int effectiveSampleRate = (request.sampleRate > 0) ? request.sampleRate : static_cast(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false)); // Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE // can read 0 on a project that has never explicitly pinned a sample rate (e.g. // brand-new projects before the user has visited the project settings). Forcing // RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by // moving away from blind passthrough. When the rate is unknown, leave // RENDER_SRATE unset so REAPER follows its own project-rate default — which is // correct behaviour for that project — rather than pinning a bogus 0. if (effectiveSampleRate > 0) { GetSetProjectInfo(proj, "RENDER_SRATE", static_cast(effectiveSampleRate), 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 using the ground-truth base64 blob for the chosen depth. // Int16/Int24 are not implemented (no live-captured blob) — fail explicitly // rather than silently mis-render at the wrong bit depth. const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth); if (!fmtBase64) { result.status = CaptureStatus::UnsupportedFormat; result.message = "Requested bit depth has no verified RENDER_FORMAT blob " "(M3 supports Float32 only; Int16/Int24 are M7+)."; return result; // guard's dtor restores every RENDER_* setting here. } setProjString(proj, "RENDER_FORMAT", fmtBase64); // --- Trigger the render ------------------------------------------------- // DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs // the render synchronously on the current build. REAPER will show its // offline-render progress window for the duration (see file-top comment — // the progress UI is not suppressible via stock API). 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; // Store the resolved sample rate only when it is known (> 0). If the project // never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE // either, so the render ran at REAPER's project default — an unknown value from // this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather // than store a bogus literal; M6/M7 can fill it in by probing the rendered file. s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown s.lengthSeconds = request.endSeconds - request.startSeconds; s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) s.tier = Tier::Scratch; // captures land in scratch by default // 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