// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared // backend helpers (makeUniqueTag / captureNameFor / collapseCapturedFileToMono / // stampCaptureSample). // // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is // the one TU that defines the API pointers; here they are extern. // // Drives the RENDER_* project settings via GetSetProjectInfo/_String (source- // selection bits come from the pure render_settings mapping), snapshots and // restores every setting it changes, triggers a render, then populates a Sample. // Source-agnostic: never reads the DAW selection itself, only the CaptureRequest // the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never // inserts into the arrange. // // RENDER PROGRESS WINDOW: triggering kActionRenderUsingMostRecentSettings (42230) // shows REAPER's offline-render progress dialog for the render's duration; no // RENDER_SETTINGS bit or GetSetProjectInfo desc suppresses it — inherent to // REAPER's offline render path. The dialog-free alternative is the realtime- // record backend, which captures the master bus to a temp track during playback // and never invokes the offline render pipeline. #include "shell/capture/capture.h" #include #include #include #include #include #include #include #include #include #include "core/capture/capture_paths.h" #include "core/capture/wav_codec.h" // hashWavContent / collapseToMono — the one WAV/RIFF owner #include "core/util/file_bytes.h" #include "core/capture/render_settings.h" #include "core/capture/render_window.h" // frameCountFor — the exact-bounds number #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 #define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime #include "reaper_plugin_functions.h" namespace reasampler::capture { namespace { // The no-dialog render is the built-in action "File: Render project, using the // most recent render settings" — command id 42230. Stock main action id, not in // reaper_plugin_functions.h, so it cannot be verified against the SDK header // (offline capture shipping as the default is the empirical evidence it holds). // Renders headlessly using whatever RENDER_* settings are currently on the // project — why we set them all explicitly first. constexpr int kActionRenderUsingMostRecentSettings = 42230; // RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves // for exact, unrounded bounds). SDK header ~3042. constexpr double kBoundsCustom = 0.0; // RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure // tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below. // RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header ~3050). // Float32 doesn't need dither, but an enabled project dither setting would // otherwise apply and break bit-identical repeats. Force off. constexpr double kDitherDisableAll = 16.0; // 32-bit IEEE float: lossless, needs no dither, so identical inputs render // bit-identically and a dry capture nulls exactly against its source. 16/24-bit // int paths need dither for correctness, which is nondeterministic. // // GetSetProjectInfo_String("RENDER_FORMAT", ...) takes the BASE64-ENCODED sink // config, not raw bytes (SDK header ~3114) — raw bytes are silently rejected and // REAPER falls back to its project default format. // // Ground truth captured from a live REAPER set to WAV/32-bit float. Decodes to // 7 bytes: "evaw" (WAV fourcc, LE) + 0x20 (float bit-depth) + 0x00 0x00 (flags). constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA=="; // Int16/Int24 blobs aren't implemented — no live-captured ground truth exists; // do not guess the encoding. Returns nullptr for unsupported depths. const char* wavSinkConfigBase64(WavBitDepth depth) { switch (depth) { case WavBitDepth::Float32: return kRenderFormatWavFloat32; case WavBitDepth::Int16: return nullptr; // capture ground-truth blob first case WavBitDepth::Int24: return nullptr; // capture ground-truth blob first } return nullptr; } // RENDER_* settings are project-GLOBAL; snapshot every value we touch and // restore on the way out via RAII so early returns can't 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 double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold 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.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 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; 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); GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, 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; }; } // namespace std::string makeUniqueTag(const std::string& prefix) { // Timestamp + per-session monotonic counter: the timestamp alone has one-second // resolution, so two captures of the same baseName within a second (batch // capture) collided on file stem and Sample id. This varies the file NAME, not // the audio bytes — bit-identical-repeat is about identical content per request. // Residual: the counter resets per-process, so a same-second collision across // two REAPER instances (or a mid-session reload) remains theoretically possible; // scoped deliberately to the reachable single-process case. static std::atomic counter{0}; const std::time_t now = std::time(nullptr); return prefix + std::to_string(static_cast(now)) + "-" + std::to_string(++counter); } CaptureName captureNameFor(const std::vector& sourceNames, int ordinal, const std::string& fallback) { CaptureNameInputs in; in.sourceNames = sourceNames; in.ordinal = ordinal; in.fallback = fallback; // localtime, not gmtime: the discriminator is read by the person who made the // capture, so it must match the clock on their wall. A failed conversion leaves the // stamp zeroed, which composeCaptureName renders as no discriminator at all. const std::time_t now = std::time(nullptr); std::tm local{}; #ifdef _WIN32 const bool ok = (localtime_s(&local, &now) == 0); #else const bool ok = (localtime_r(&now, &local) != nullptr); #endif if (ok) { in.stamp.month = local.tm_mon + 1; // tm_mon is 0-based in.stamp.day = local.tm_mday; in.stamp.hour = local.tm_hour; in.stamp.minute = local.tm_min; } return composeCaptureName(in); } namespace { // One console line per genuine collapse failure — silence here is what made a failed // rewrite read exactly like a legitimately stereo capture. Deliberately does NOT claim // the captured bytes are intact: a 0-byte render can reach this branch too (it passes the // exists/bounds gates upstream; see docs/TODO.md), and this path never verified the bytes // it's reporting on. void reportCollapseFailure(const std::string& absolutePath, const char* what, const char* consoleLabel) { ShowConsoleMsg((std::string(consoleLabel) + ": the lossless mono collapse " + std::string(what) + " -- " + absolutePath + " already reached the bank; only the size win from the collapse " "was lost.\n").c_str()); } } // namespace MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath, const char* consoleLabel) { const std::vector bytes = util::readFileBytes(absolutePath); if (bytes.empty()) { // Failed, not Declined: the read that would have decided never happened, so // "the channels differ" is a claim this path cannot make. reportCollapseFailure(absolutePath, "could not read the captured file", consoleLabel); return MonoCollapseOutcome::Failed; } const MonoCollapse collapse = collapseToMono(bytes); if (!collapse.collapsed) return MonoCollapseOutcome::Declined; // Sibling temp + rename, NOT an in-place truncating write: this runs unconditionally // on the deterministic offline path (which never reopened its render for write before // this step existed), so a mid-write failure here must not land a truncated file that // stampCaptureSample then hashes as a false CaptureStatus::Ok. rename() replaces the // destination in one step, so the original bytes are never destroyed until the // replacement is known-complete; a failed write or rename leaves the original file // untouched and self-cleans the temp rather than littering it. const std::string tempPath = absolutePath + ".moncollapse.tmp"; { std::ofstream out(tempPath, std::ios::binary | std::ios::trunc); if (!out) { reportCollapseFailure(absolutePath, "could not open its temporary file", consoleLabel); return MonoCollapseOutcome::Failed; } out.write(reinterpret_cast(collapse.bytes.data()), static_cast(collapse.bytes.size())); const bool wroteOk = static_cast(out); out.close(); if (!wroteOk) { std::error_code ec; std::filesystem::remove(tempPath, ec); reportCollapseFailure(absolutePath, "could not write the rebuilt file", consoleLabel); return MonoCollapseOutcome::Failed; } } std::error_code ec; std::filesystem::rename(tempPath, absolutePath, ec); if (ec) { std::filesystem::remove(tempPath, ec); // don't leave litter on a failed rename reportCollapseFailure(absolutePath, "could not replace the captured file", consoleLabel); return MonoCollapseOutcome::Failed; } return MonoCollapseOutcome::Collapsed; } void stampCaptureSample(Sample& s, const CaptureRequest& req, ReaProject* rateProj, ReaProject* timeSigProj, const std::string& absolutePath) { // Track GUIDs echoed from the request (the caller resolved the selection; the // backends stay source-agnostic). channelCount starts at 0 (unknown, the same // sentinel bank_model already uses for a pre-field entry) rather than the // request's value — the request always asks for 2, so echoing it would claim a // measurement that never happened for the unparseable-file case below. The // produced FILE overrides it below whenever it parses. s.trackGuids = req.trackGuids; s.channelCount = 0; // PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0 // (honest "unknown") rather than a bogus literal. s.sampleRate = (req.sampleRate > 0) ? req.sampleRate : static_cast(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false)); s.captureTempo = Master_GetTempo(); // BPM at capture time // Time signature effective at the capture's START time, so a sample captured // under 3/4 keeps a 3/4 read-out even if the project later switches to 4/4. // `timeSigProj` is the caller's project pin — offline passes nullptr (active // project); realtime pins the record's own project. tempoOut is ignored — // captureTempo already carries it. { int tsNum = 0, tsDenom = 0; double tsTempo = 0.0; TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo); s.captureTimeSigNum = tsNum; s.captureTimeSigDenom = tsDenom; } // hashWavContent (not raw hashBytes) skips render-varying metadata chunks // (bext timestamp, iXML, LIST/INFO) so identical audio from two renders/records // collapses to the same hash, letting dedup find copies across banks. // Unreadable file leaves contentHash empty (bank_model treats "" as non-dedup). { const std::vector fileBytes = util::readFileBytes(absolutePath); if (!fileBytes.empty()) { s.contentHash = hashWavContent(fileBytes); // The one authority for the entry's channel count is the file's own `fmt` // — never the render request, which asks for 2 on every capture path. const WavLayout layout = parseWavLayout(fileBytes); if (layout.valid) s.channelCount = static_cast(layout.channelCount); } } s.createdTimestamp = static_cast(std::time(nullptr)); } CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { CaptureResult result; // SourceMode::Realtime is refused here — that's the realtime backend's job — // so the offline path never silently renders the wrong thing. const RenderSettingsChoice choice = renderSettingsFor(request.sourceMode, request.wetDry); if (!choice.supported) { result.status = CaptureStatus::UnsupportedMode; result.message = "OfflineRenderBackend does not render this source mode " "(realtime capture is the realtime backend)."; 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; } // idx -1 == the active project tab. ReaProject* proj = EnumProjects(-1, nullptr, 0); if (!proj) { result.status = CaptureStatus::NoProject; result.message = "No active project."; return result; } // Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path // out-param is empty for a project that has never been saved — a reliable // unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not // the .rpp location, and is never empty even when unsaved (the original bug — // captures landed in REAPER's default media location instead of by the .rpp). // // Flow: read .rpp path; if empty, Main_SaveProject(proj, true) — true = // forceSaveAsIn — prompts Save-As and (per SDK header) blocks until dismissed, // though that blocking behaviour cannot be verified from the header itself; // re-read; if still empty (cancelled), refuse with NoProject and write nothing. auto readRppPath = [&]() -> std::string { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); return std::string(buf.data()); }; std::string rppPath = readRppPath(); if (rppPath.empty()) { Main_SaveProject(proj, true); rppPath = readRppPath(); } if (rppPath.empty()) { result.status = CaptureStatus::NoProject; result.message = "Project must be saved before capture — nothing captured."; return result; } // Project dir = parent of the .rpp; forward-slash-normalized 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(); for (char& c : dir) { if (c == '\\') c = '/'; } if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); return dir; }(); // Compute the tag ONCE — calling makeUniqueTag() twice would let the file // stem and Sample.id diverge (the counter advances per call). const std::string uniqueTag = makeUniqueTag(""); const BankPaths paths = deriveBankPaths(projectDir, request.baseName, uniqueTag); ScopedRenderSettings guard(proj); // 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); // TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact // bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no // trim. NORMALIZE is driven here (not the determinism block below) so the // Auto surgical value isn't clobbered. const TailRenderSettings tail = tailRenderSettingsFor(request.tailMode, request.tailMs); GetSetProjectInfo(proj, "RENDER_TAILFLAG", static_cast(tail.tailFlag), true); GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true); // Source-selection bits for this mode (SDK header ~3041), all wet-only: // master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file. GetSetProjectInfo(proj, "RENDER_SETTINGS", static_cast(choice.settings), true); // request 0 = "follow project"; PROJECT_SRATE is still readable via // GetSetProjectInfo even when PROJECT_SRATE_USE is clear. const int effectiveSampleRate = (request.sampleRate > 0) ? request.sampleRate : static_cast(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false)); // Only pin RENDER_SRATE when known (>0) — a brand-new project can read 0 for // PROJECT_SRATE, and forcing RENDER_SRATE=0 would be a bogus literal; leave // it unset so REAPER follows its own project-rate default. if (effectiveSampleRate > 0) { GetSetProjectInfo(proj, "RENDER_SRATE", static_cast(effectiveSampleRate), true); } GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(request.channelCount), true); // Load-bearing: never add the rendered file to the project as an item. GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true); GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true); // None/Manual -> disable-all (byte-identical to pre-tail); Auto -> surgical // trim-end (only &32768) + -72 dB TRIMEND — a fixed-threshold trailing-silence // trim scales/limits/fades nothing, so Auto stays deterministic. TRIMEND is // only consulted when the trim bit is set but written unconditionally for clarity. GetSetProjectInfo(proj, "RENDER_NORMALIZE", static_cast(tail.normalize), true); GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true); // RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the // format extension. paths.fileStem already owns the .wav suffix knowledge. setProjString(proj, "RENDER_FILE", paths.absoluteDir); setProjString(proj, "RENDER_PATTERN", paths.fileStem); // Int16/Int24 have no captured ground-truth 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 " "(Float32 only; Int16/Int24 not yet supported)."; return result; } setProjString(proj, "RENDER_FORMAT", fmtBase64); Main_OnCommand(kActionRenderUsingMostRecentSettings, 0); // Main_OnCommand returns void, so a failed render is silent — stat the // expected output path to detect it. 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; } // Exact bounds, made structural: with no tail requested the file must contain // (within a tolerance, see below) the requested window's frames, so a source // mode that silently widened the render fails loudly here instead of landing as // a successful capture. Auto and Manual add frames by design and are skipped. // (On TailMode::None the landed file is read three times on this path — this gate, // the mono collapse, and stampCaptureSample — plus one rewrite when the collapse // fires; Auto/Manual skip this gate entirely, so they read it twice. A // once-per-capture cost on an already-warm file, judged acceptable.) A // bounded/header-only read is not a clean substitute: parseWavLayout only marks the // data chunk valid when the buffer holds the chunk's FULL declared body // (bodyInBounds), so a truncated read would read as invalid here on every real // capture, not just malformed ones. if (request.tailMode == TailMode::None) { const WavLayout layout = parseWavLayout(util::readFileBytes(expectedPath)); const long long expectedFrames = layout.valid ? frameCountFor(request.startSeconds, request.endSeconds, static_cast(layout.sampleRate)) : 0; const long long actualFrames = static_cast(layout.frameCount()); // frameCountFor is a difference of frame indices, not a rounded duration // (see render_window.h) — REAPER's own edge-rounding can legitimately land // one frame off that, so the gate tolerates +/-1 rather than exact equality. // The defect this refuses is a whole-item widening (seconds of extra audio, // thousands of frames), which a 1-frame tolerance still catches with // certainty. Tightening to exact equality needs a DAW pass confirming REAPER // resolves the window's two edges to frame indices the same way this does. const long long frameDelta = actualFrames > expectedFrames ? actualFrames - expectedFrames : expectedFrames - actualFrames; if (expectedFrames > 0 && frameDelta > 1) { result.status = CaptureStatus::BoundsMismatch; result.message = "Render produced " + std::to_string(actualFrames) + " frames but the requested range is " + std::to_string(expectedFrames) + " at " + std::to_string(layout.sampleRate) + " Hz -- the render did not honor the requested bounds. " "Requested [" + std::to_string(request.startSeconds) + "s, " + std::to_string(request.endSeconds) + "s) -> frame indices [" + std::to_string(std::llround(request.startSeconds * layout.sampleRate)) + ", " + std::to_string(std::llround(request.endSeconds * layout.sampleRate)) + "). Nothing was added to the bank; the render at " + expectedPath + " was never indexed and has been cleaned up."; std::error_code ec; std::filesystem::remove(expectedPath, ec); return result; } } // Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures // REAPER's own render against the requested window, so nothing of ours may sit // between the render and that measurement, and a refusal must delete the // renderer's file rather than one this step had already rewritten. The collapse // preserves the frame count, so the two are order-independent in outcome — only // in what each is measuring. const MonoCollapseOutcome collapseOutcome = collapseCapturedFileToMono(expectedPath); // Record the request's own bounds (exact) rather than re-measuring the file. Sample s; // Same uniqueTag that named the file — calling makeUniqueTag() again could // yield a different value and desync Sample.id from the file name. s.id = "cap-" + uniqueTag + "-" + paths.fileName; s.displayName = request.label(); s.relativePath = paths.relativePath; // project-relative (invariant) s.sourceMode = request.sourceMode; s.sourceRange.startSeconds = request.startSeconds; s.sourceRange.endSeconds = request.endSeconds; // startPpq/endPpq/lengthBeats left at 0 — PPQ mapping is a placement-time // concern; seconds are the authoritative source for the render and we don't // re-derive one bound from the other. s.wetDry = request.wetDry; s.lengthSeconds = request.endSeconds - request.startSeconds; s.tier = model::Tier::Scratch; stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath); // rootNote/loop left empty — a master/track/time-selection render isn't a // single played note, so no root note is derivable; loop points are set // later by an explicit user action. result.status = CaptureStatus::Ok; result.sample = s; result.message = "Captured [" + std::to_string(request.startSeconds) + "s, " + std::to_string(request.endSeconds) + "s] -> " + paths.relativePath + monoCollapseSuffix(collapseOutcome); return result; } } // namespace reasampler::capture