// capture_realtime_finalize.cpp — recorded-file discovery, move-into-bank, the // Auto-tail decay-scan trim, and finished-Sample population. See the header. // // 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. #include "shell/capture/capture_realtime_finalize.h" #include #include #include #include #include #include "core/audio/peaks.h" // lastFrameAboveThreshold #include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture #include "core/capture/render_settings.h" // autoTrimEndRatio #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE #include "core/util/file_bytes.h" // shared whole-file loader #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetTrackNumMediaItems #define REAPERAPI_WANT_GetTrackMediaItem #define REAPERAPI_WANT_GetMediaItemTake #define REAPERAPI_WANT_GetMediaItemTake_Source #define REAPERAPI_WANT_GetMediaSourceFileName #include "reaper_plugin_functions.h" namespace reasampler::capture { namespace { std::string normSlashes(std::string s) { for (char& c : s) if (c == '\\') c = '/'; if (s.size() > 1 && s.back() == '/') s.pop_back(); return s; } // Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime): after the // recorded file is moved into the bank (the file we OWN, never the project), scan // the tail (frames after the original range end) backward for the last frame above // -72 dB and truncate there. Rules: // * no tail frame above -72 dB -> trim back to the range end // * signal never drops below -72 dB -> keep the full window (cap did its job) // * otherwise -> trim one frame past the last audible // // Returns the trimmed length in seconds, or negative for "no trim applied". Any // unreadable/unknown/short file skips the trim rather than risk corrupting the // capture — this is a convenience path, not a correctness one. // // Assumes the recorded file is a canonical 32-bit float WAV, fully flushed/closed // before this runs (tick()'s Finalizing size-stable wait guarantees that on the // normal path; abort()'s best-effort finalize can race it). double trimAutoTailInPlace(const std::string& path, double rangeStartSeconds, double rangeEndSeconds) { constexpr double kNoTrim = -1.0; std::vector bytes = util::readFileBytes(path); if (bytes.empty()) return kNoTrim; const WavLayout layout = parseWavLayout(bytes); if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim const std::size_t totalFrames = layout.frameCount(); if (totalFrames == 0) return kNoTrim; // Range end as a frame index (frame 0 == start), using the file's own sample // rate (authoritative — the request rate may be 0 = follow project). Clamped to // the file so a rounding overshoot cannot exceed it. const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; if (rangeSeconds <= 0.0) return kNoTrim; std::size_t rangeEndFrame = static_cast( rangeSeconds * static_cast(layout.sampleRate) + 0.5); if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; if (rangeEndFrame >= totalFrames) return kNoTrim; // tail window was empty // Scan only the tail region — the trim never eats into the range body. const std::size_t tailFrames = totalFrames - rangeEndFrame; const std::vector tailPcm = extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); if (tailPcm.empty()) return kNoTrim; const float threshold = static_cast(autoTrimEndRatio()); const std::size_t lastAbove = audio::lastFrameAboveThreshold( tailPcm, layout.channelCount, tailFrames, threshold); // keptFrames: the trimmed file's total frame count. No audible tail frame -> trim // back to rangeEndFrame; an audible frame at idx -> keep through that frame. The // "never drops below threshold" case falls out naturally: lastAbove is the final // tail frame, so keptFrames == totalFrames. std::size_t keptFrames; if (lastAbove == audio::kNoFrameAboveThreshold) { keptFrames = rangeEndFrame; } else { keptFrames = rangeEndFrame + (lastAbove + 1); } if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate const WavTruncatePlan plan = planWavTruncate(layout, keptFrames); if (!plan.valid) return kNoTrim; // Patch RIFF + data size fields to the kept frame count (wav_codec's patch // primitive — the one RIFF owner), then rewrite the file as exactly the first // newFileByteLength bytes. A single truncating write avoids a separate resize // step and any partial-write window where on-disk sizes and length disagree. patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); // A mid-write failure (full disk, yanked drive) would leave a short file while we // return kNoTrim, overstating the Sample length. Vanishingly unlikely for a // just-recorded local file, and this is a convenience path, so a temp-file+ // atomic-rename isn't warranted; flagged rather than built. std::ofstream out(path, std::ios::binary | std::ios::trunc); if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file out.write(reinterpret_cast(bytes.data()), static_cast(plan.newFileByteLength)); if (!out) return kNoTrim; out.close(); // The trimmed length in seconds for the Sample metadata. return static_cast(keptFrames) / static_cast(layout.sampleRate); } } // namespace std::string recordedFilePath(MediaTrack* temp) { if (!temp) return {}; if (GetTrackNumMediaItems(temp) <= 0) return {}; MediaItem* item = GetTrackMediaItem(temp, 0); if (!item) return {}; MediaItem_Take* take = GetMediaItemTake(item, 0); if (!take) return {}; PCM_source* src = GetMediaItemTake_Source(take); if (!src) return {}; std::vector buf(4096, '\0'); GetMediaSourceFileName(src, buf.data(), static_cast(buf.size())); return normSlashes(std::string(buf.data())); } CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, const CaptureRequest& request, const BankPaths& paths, const std::string& uniqueTag, double recordWindowEnd) { CaptureResult result; const std::string recorded = recordedFilePath(temp); if (recorded.empty() || !std::filesystem::exists(recorded)) { result.status = CaptureStatus::RenderFailed; result.message = "Realtime record produced no file (check transport/record " "settings in the DAW)."; return result; } std::error_code ec; std::filesystem::create_directories(paths.absoluteDir, ec); const std::string destPath = paths.absoluteDir + "/" + paths.fileName; std::filesystem::rename(recorded, destPath, ec); if (ec) { // Cross-volume rename can fail; fall back to copy+remove. ec.clear(); std::filesystem::copy_file( recorded, destPath, std::filesystem::copy_options::overwrite_existing, ec); if (ec) { result.status = CaptureStatus::RenderFailed; result.message = "Recorded file could not be moved into the bank: " + ec.message(); return result; } std::error_code rmEc; std::filesystem::remove(recorded, rmEc); // best-effort } // Only Auto trims; None recorded exact bounds and Manual is a fixed window // (spec §The realtime path). double trimmedLenSeconds = -1.0; if (request.tailMode == TailMode::Auto) { trimmedLenSeconds = trimAutoTailInPlace(destPath, request.startSeconds, request.endSeconds); } // Channel-domain rewrite, after the frame-domain trim so it acts on the final // frame set; it preserves the frame count, so the trimmed length above still holds. const MonoCollapseOutcome collapseOutcome = collapseCapturedFileToMono(destPath, "ReaSampler realtime capture"); // Pure recorded-capture -> Sample mapping (identity, bounds echo, tier). RecordedCapture cap; cap.relativePath = paths.relativePath; cap.uniqueTag = uniqueTag; cap.sourceMode = SourceMode::Realtime; cap.startSeconds = request.startSeconds; cap.endSeconds = request.endSeconds; cap.wetDry = request.wetDry; cap.displayName = request.label(); cap.trackGuids = request.trackGuids; // channelCount deliberately left unset here: stampCaptureSample measures it from // the file below. Echoing the request was this path's own defect — it parsed the // recorded layout for the trim and still reported the requested 2. result.status = CaptureStatus::Ok; result.sample = sampleFromRecordedCapture(cap); // Shared finished-capture stamp. timeSigProj = proj: the realtime path pins the // record's own project (offline reads the active project instead) — the // divergence is kept caller-visible here. stampCaptureSample(result.sample, request, /*rateProj=*/proj, /*timeSigProj=*/proj, destPath); // The recorded length differs from the request range when a tail was recorded, // so lengthSeconds must reflect the file, not the range: trimmed length if Auto // trimmed, else the full recorded window (recordWindowEnd - start; equals the // exact range when tailMode is None). if (trimmedLenSeconds >= 0.0) { result.sample.lengthSeconds = trimmedLenSeconds; } else { result.sample.lengthSeconds = recordWindowEnd - request.startSeconds; } result.message = "Realtime-captured [" + std::to_string(request.startSeconds) + "s, " + std::to_string(request.endSeconds) + "s] (recorded " + std::to_string(result.sample.lengthSeconds) + "s) -> " + paths.relativePath + monoCollapseSuffix(collapseOutcome); return result; } } // namespace reasampler::capture