#include "core/namespaces.h" // capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend). // // 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). // // Captures the requested scope over the requested range by RECORDING in realtime // (transport-driven) into a hidden temp track, then moves the recorded file into // the bank as a Sample — non-destructively. This increment implements the TRACK // scope only (records the selected track's own output). Item realtime is deferred // (UnsupportedMode) rather than silently half-built. // // ============================================================================ // §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right") // ============================================================================ // A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a // bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES // REAPER's UI for the whole record. That is gone. The record is now driven across // timer ticks: // begin() — validate, snapshot ALL state to restore, create the temp track, // route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY. // tick() — (from OnTimer, the same tick as session.poll()) read the transport, // and on a terminal verdict stop + finalize/abort + RESTORE everything. // abort() — force-terminate now (shutdown / project switch) + RESTORE everything. // // The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope // RAII guard — because the record spans ticks, no single stack frame outlives it. // restore() is idempotent (a restored_ latch): every terminal path — normal // completion, user stop, error, second-capture reject, project switch, unload — // funnels through the SAME single restore, safe to call once from whichever fires. // The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the // completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp} // (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe. // // ============================================================================ // §TAP — track-output tap (selected track's own output, PRE-parent) // ============================================================================ // The recipe: the hidden temp track RECEIVES a send FROM each selected source track // (CreateTrackSend(source, temp)). The temp track records its OWN output // (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into // the master — no feedback, no monitoring double). Multiple selected tracks each get // a send into the one temp track, so their outputs SUM in the temp track — matching // how offline track scope handles a multi-track selection. // // WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard: // A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0 // (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader // taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly // the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is // a branch off the signal at the track's output stage; the parent chain downstream // of that branch is not in the tapped path AT ALL. So the tap is chain-independent // BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates // the live chain, altering the user's monitoring) is deliberately NOT used. This is // the realtime analogue of offline track scope (item + the track's own FX + its own // fader/pan; parent/folder/master excluded), reached without touching any live FX. // // This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO // a temp track, which REAPER refuses to carry (master->track is a feedback loop), so // the temp recorded silence. A regular track->track send has no feedback — it works. // // Non-destructive: the temp track is deleted on teardown, which removes every send we // created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so // NO source track retains any routing change. We never mutate any existing track's // persistent state; we only add sends FROM the source tracks that vanish with the // temp track. The selected source tracks are UNCHANGED after capture. // // Item realtime is deferred (UnsupportedMode): item scope would need per-item take // isolation on top of the tap, which is a separate increment. #include "shell/capture/capture.h" #include #include #include #include #include #include #include #include #include "core/capture/capture_paths.h" // hashBytes, deriveBankPaths #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "core/audio/peaks.h" // lastFrameAboveThreshold, AudioSample #include "core/capture/realtime_record.h" #include "core/capture/render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd #include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_Main_SaveProject #define REAPERAPI_WANT_Master_GetTempo #define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime #define REAPERAPI_WANT_GetSetProjectInfo #define REAPERAPI_WANT_InsertTrackAtIndex #define REAPERAPI_WANT_DeleteTrack #define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_GetTrack #define REAPERAPI_WANT_CreateTrackSend #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value #define REAPERAPI_WANT_GetTrackNumMediaItems #define REAPERAPI_WANT_GetTrackMediaItem #define REAPERAPI_WANT_GetMediaItemTake #define REAPERAPI_WANT_GetMediaItemTake_Source #define REAPERAPI_WANT_GetMediaSourceFileName #define REAPERAPI_WANT_CSurf_OnRecord #define REAPERAPI_WANT_OnStopButtonEx #define REAPERAPI_WANT_GetPlayStateEx #define REAPERAPI_WANT_GetPlayPositionEx #define REAPERAPI_WANT_GetSet_LoopTimeRange #define REAPERAPI_WANT_GetCursorPosition #define REAPERAPI_WANT_SetEditCurPos #define REAPERAPI_WANT_ValidatePtr2 #include "reaper_plugin_functions.h" namespace reasampler { namespace { // A monotonic, filesystem-safe timestamp tag so repeated captures do not collide. std::string makeUniqueTag() { std::time_t now = std::time(nullptr); return "rt-" + std::to_string(static_cast(now)); } std::string normSlashes(std::string s) { for (char& c : s) if (c == '\\') c = '/'; if (s.size() > 1 && s.back() == '/') s.pop_back(); return s; } // Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin() // time, when the record's project IS the active project. std::string readRppPath() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); return std::string(buf.data()); } // Discovers the file REAPER actually recorded onto the temp track: the first media // item's active take's source file. Empty string if nothing was recorded. 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 std::string(buf.data()); } // The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no // item/take/source, or the file does not exist on disk this tick). Used by the flush // wait to detect stability (size unchanged across a tick) BEFORE moving the file — a // take REAPER is still flushing on the audio thread grows tick over tick. std::int64_t recordedFileSize(MediaTrack* temp) { const std::string path = recordedFilePath(temp); if (path.empty()) return -1; std::error_code ec; const auto sz = std::filesystem::file_size(path, ec); if (ec) return -1; return static_cast(sz); } } // namespace // ============================================================================ // RealtimeCaptureState — the in-flight snapshot + idempotent restore // ============================================================================ // Holds EVERYTHING to restore across the many ticks the record spans (temp track + // its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection), // plus the request echo needed to finalize the Sample. restore() is idempotent // (restored_ latch) and is the single teardown every terminal path calls. class RealtimeCaptureState { public: // Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so // a project switch mid-record cannot read the wrong transport), the request // echo, and the resolved bank paths + tag for finalize. ReaProject* proj_ = nullptr; CaptureRequest request_; BankPaths paths_; std::string uniqueTag_; // The RECORDED window end in project seconds (>= request_.endSeconds). For a tail // mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set // length), so this — not request_.endSeconds — is the end the completion state // machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds). double recordWindowEnd_ = 0.0; // The transient sink. The sends we create (from each selected source track INTO // temp_) live on those source tracks pointing AT temp_, and are removed automatically // when temp_ is deleted — REAPER cannot leave a send dangling to a deleted // destination. So there is no separate send handle to track here. MediaTrack* temp_ = nullptr; // The record phase (pure state machine drives the transition). Starts Recording. RecordPhase phase_ = RecordPhase::Recording; // Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not // the play cursor — so a stuck/looping transport is still caught, review §3). // begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing // edge (the transport stop) so the flush wait is bounded from the stop, not begin. std::chrono::steady_clock::time_point begunAt_{}; std::chrono::steady_clock::time_point finalizingAt_{}; // Deferred-finalize (review §2) flush tracking: the recorded file's size the // previous tick, so "size unchanged across a tick" signals REAPER finished // flushing/closing the take. -1 = not yet seen. std::int64_t lastFileSize_ = -1; void markElapsedStart() { begunAt_ = std::chrono::steady_clock::now(); } double elapsedSeconds() const { return std::chrono::duration( std::chrono::steady_clock::now() - begunAt_).count(); } // Set the flush-wait anchor once, on the first Finalizing tick. void markFinalizingStartOnce() { if (finalizingAt_.time_since_epoch().count() == 0) finalizingAt_ = std::chrono::steady_clock::now(); } double finalizingSeconds() const { if (finalizingAt_.time_since_epoch().count() == 0) return 0.0; return std::chrono::duration( std::chrono::steady_clock::now() - finalizingAt_).count(); } // Snapshot of state to restore. Filled at begin(), replayed once by restore(). double curPos_ = 0.0; double tsStart_ = 0.0; double tsEnd_ = 0.0; struct ArmSnap { MediaTrack* track; double recarm; }; std::vector armSnaps_; // Snapshot the transport-adjacent state (cursor + time selection) and every // OTHER track's arm, disarming them so only our sink records. Call ONCE, before // the temp track exists (so the temp track is never in the arm snapshot). void snapshotAndDisarmOthers() { curPos_ = GetCursorPosition(); GetSet_LoopTimeRange(false, false, &tsStart_, &tsEnd_, false); const int n = CountTracks(proj_); for (int i = 0; i < n; ++i) { MediaTrack* tr = GetTrack(proj_, i); if (!tr) continue; const double armed = GetMediaTrackInfo_Value(tr, "I_RECARM"); if (armed != 0.0) { armSnaps_.push_back({tr, armed}); SetMediaTrackInfo_Value(tr, "I_RECARM", 0.0); } } } // The single, idempotent teardown. Called on EVERY terminal path (normal // completion, user stop, error, project switch, unload). Safe to call more than // once — the restored_ latch makes every call after the first a no-op. Order: // 1. stop the transport if anything is still running (we own it), // 2. delete the temp track (drops its receive-sum sends + the recorded item), // 3. restore every other track's arm, // 4. restore the time selection + edit cursor. // Stop the record's OWN project transport if it is still playing/recording. Uses // the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a // project switch mid-record — where proj_ is no longer the ACTIVE project — stops // OUR project's transport, never the foreign now-active one. &1=playing, // &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op). void stopOwnTransport() { if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_); } // Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured // project was CLOSED mid-record, proj_/temp_ point at freed memory; // touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free. // ValidatePtr2 with a null project validates the ReaProject* itself (the header: // "proj is ignored if pointer is itself a project"). Every teardown that // dereferences a captured REAPER object MUST gate on this first. bool captureProjectStillOpen() const { return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*"); } // Drop the handle WITHOUT touching any REAPER state — for the closed-project case // (review §1). A closed project already reclaimed its temp track, arms, and // transport; there is nothing to restore and the pointers are freed. Latch // restored_ so any later terminal path is a no-op (idempotent), but skip every // REAPER call restore() would make. void dropWithoutRestore() { restored_ = true; temp_ = nullptr; armSnaps_.clear(); } void restore() { if (restored_) return; restored_ = true; // 1. Transport: stop OUR project's if still running (usually already stopped // by the terminal path's explicit stop-before-finalize — a safe no-op then). stopOwnTransport(); // 2. Temp track: deleting it drops the source-track sends (REAPER removes every // send whose destination is deleted — no source track is left mutated) AND the // recorded arrange item in one move — nothing stays behind (load-bearing). if (temp_) { DeleteTrack(temp_); temp_ = nullptr; } // 3. Other tracks' record-arm. for (const ArmSnap& s : armSnaps_) SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm); armSnaps_.clear(); // 4. Time selection + edit cursor (no view move, no seek). GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false); SetEditCurPos(curPos_, false, false); } bool restored() const { return restored_; } bool finalized() const { return finalized_; } void markFinalized() { finalized_ = true; } private: bool restored_ = false; bool finalized_ = false; }; namespace { // Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): // empty on any I/O failure — the caller treats an unreadable file as "skip the // trim" (keep the untrimmed window), never as a corruption of the recorded audio. // Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). void writeU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { bytes[off + 0] = static_cast(v & 0xFF); bytes[off + 1] = static_cast((v >> 8) & 0xFF); bytes[off + 2] = static_cast((v >> 16) & 0xFF); bytes[off + 3] = static_cast((v >> 24) & 0xFF); } // ============================================================================ // §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) // ============================================================================ // After the recorded file is stable and moved into the bank (the file we OWN — never // the project), Auto mode trims the trailing decay: read the WAV, scan the tail // region (frames AFTER the original range end) backward for the last frame above // -72 dB, and truncate the file there. Rules (spec): // * no frame in the tail window above -72 dB -> trim back to the original range end // * signal never falls below -72 dB in window -> keep the full window (cap did its job) // * otherwise -> trim one frame past the last audible // // Returns the trimmed length in SECONDS (for the Sample), or a negative value to // signal "no trim applied" (caller keeps the pre-trim length). Best-effort and // non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) // rather than risk corrupting the capture — realtime tail is a convenience path. // // FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit // float WAV (REAPER project record format — the manual procedure sets it) and is fully // flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees // that for the normal path; abort()'s best-effort finalize races it, documented). double trimAutoTailInPlace(const std::string& path, double rangeStartSeconds, double rangeEndSeconds) { constexpr double kNoTrim = -1.0; std::vector bytes = readFileBytes(path); if (bytes.empty()) return kNoTrim; const reasampler::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; // The original range end as a frame index within the file (frame 0 == start). Use // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow // project). Clamp 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; // Nothing recorded past the range end (the tail window was empty) -> nothing to // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) if (rangeEndFrame >= totalFrames) return kNoTrim; // Scan ONLY the tail region (frames after the original range end). The trim never // eats into the range body — the scan starts at rangeEndFrame. 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(reasampler::autoTrimEndRatio()); const std::size_t lastAbove = reasampler::lastFrameAboveThreshold( tailPcm, layout.channelCount, tailFrames, threshold); // keptFrames: the total frame count the trimmed file retains. // no audible tail frame -> trim back to the range end (rangeEndFrame frames) // an audible frame at idx -> keep range body + up to and including that frame // The "signal never falls below threshold" case falls out naturally: lastAbove is // the final tail frame, so keptFrames == totalFrames (the full window is kept). std::size_t keptFrames; if (lastAbove == reasampler::kNoFrameAboveThreshold) { keptFrames = rangeEndFrame; } else { keptFrames = rangeEndFrame + (lastAbove + 1); } if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames); if (!plan.valid) return kNoTrim; // Patch the RIFF + data size fields in the in-memory buffer so they describe the // kept frame count, then rewrite the file as exactly the first newFileByteLength // bytes (header + patched sizes + retained PCM). A single truncating write is the // simplest correct truncate — no separate resize step, no partial-write window // where the on-disk sizes and length disagree. The result is a valid, playable WAV // of the kept frames (verified by the wav_trim re-parse test). writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full // disk, a yanked drive) would leave a short file while we return kNoTrim, so the // Sample length would overstate the file. Vanishingly unlikely for a just-recorded // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- // rename is not warranted here; 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); } // Builds a CaptureResult for a finalized recording: discover the recorded file, // move it into the bank, populate the Sample via the pure mapping. Returns Ok + // Sample on success, or a RenderFailed result. Does NOT restore — the caller // restores unconditionally afterward (finalize + restore are separate steps so a // finalize failure still restores). CaptureResult finalizeRecording(RealtimeCaptureState& st) { CaptureResult result; const std::string recorded = normSlashes(recordedFilePath(st.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(st.paths_.absoluteDir, ec); const std::string destPath = st.paths_.absoluteDir + "/" + st.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 } // TAIL (Auto): trim the trailing decay of the recorded window in place — on the // BANK file we now own (destPath), never the project. Best-effort: an unreadable / // unknown-format / short file skips the trim (keeps the full window) rather than // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a // fixed window (spec §The realtime path). Returns the trimmed length in seconds, // or < 0 for "no trim applied". double trimmedLenSeconds = -1.0; if (st.request_.tailMode == TailMode::Auto) { trimmedLenSeconds = trimAutoTailInPlace(destPath, st.request_.startSeconds, st.request_.endSeconds); } RecordedCapture cap; cap.relativePath = st.paths_.relativePath; cap.uniqueTag = st.uniqueTag_; cap.sourceMode = SourceMode::Realtime; cap.startSeconds = st.request_.startSeconds; cap.endSeconds = st.request_.endSeconds; cap.wetDry = st.request_.wetDry; cap.displayName = st.request_.baseName; cap.trackGuids = st.request_.trackGuids; cap.channelCount = st.request_.channelCount; cap.sampleRate = (st.request_.sampleRate > 0) ? st.request_.sampleRate : static_cast(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false)); cap.captureTempo = Master_GetTempo(); // Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime // (reaper_plugin_functions.h:7130) reads the meter effective at that project time; // proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is // the master tempo above). Leaves 0/0 (unstamped) on any failure. { int tsNum = 0, tsDenom = 0; double tsTempo = 0.0; TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo); cap.captureTimeSigNum = tsNum; cap.captureTimeSigDenom = tsDenom; } cap.createdTimestamp = static_cast(std::time(nullptr)); result.status = CaptureStatus::Ok; result.sample = sampleFromRecordedCapture(cap); // Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data // chunks so hashReferencedElsewhere can identify copies in other banks and suppress // the last-reference confirm when another bank still holds the same file. Using // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks // (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical // audio collapse to the same hash. Best-effort: an unreadable file leaves // contentHash empty — the safe, confirm-eliciting direction (bank_model treats // "" as non-participating). { const std::vector fileBytes = readFileBytes(destPath); if (!fileBytes.empty()) { result.sample.contentHash = hashWavContent(fileBytes); } } // The recorded file's true length differs from the request range when a tail was // recorded, so the Sample length must reflect the FILE, not the range: // Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. // Auto with no trim, or Manual -> the full recorded window (end - start). // None -> the exact range (unchanged; recordWindowEnd_ == endSeconds). // sampleFromRecordedCapture already set lengthSeconds = end - start; override it // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk. if (trimmedLenSeconds >= 0.0) { result.sample.lengthSeconds = trimmedLenSeconds; } else { result.sample.lengthSeconds = st.recordWindowEnd_ - st.request_.startSeconds; } result.message = "Realtime-captured [" + std::to_string(st.request_.startSeconds) + "s, " + std::to_string(st.request_.endSeconds) + "s] (recorded " + std::to_string(result.sample.lengthSeconds) + "s) -> " + st.paths_.relativePath; return result; } } // namespace // ============================================================================ // begin — start the record, snapshot, return immediately (no UI block) // ============================================================================ void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept { delete p; // full type is visible here — keeps capture.h REAPER-free } RealtimeCaptureHandle RealtimeRecordBackend::begin(const CaptureRequest& request, const std::vector& sourceTracks, CaptureResult& outFailure) { // Only the track scope is implemented this increment (see §TAP). Item realtime // is deferred — it needs per-item take isolation on top of the track-output tap. if (request.sourceMode != SourceMode::SelectedTracks) { outFailure.status = CaptureStatus::UnsupportedMode; outFailure.message = "RealtimeRecordBackend implements TRACK scope only this " "increment (item realtime is deferred)."; return nullptr; } // Track scope needs at least one source track to tap. No selection -> refuse // (matching offline track scope's no-op on an empty selection). if (sourceTracks.empty()) { outFailure.status = CaptureStatus::UnsupportedMode; outFailure.message = "No track selected — realtime track capture needs at least " "one selected track to tap."; return nullptr; } // Exact bounds: refuse an empty/inverted range rather than record silence. if (!(request.endSeconds > request.startSeconds)) { outFailure.status = CaptureStatus::EmptyRange; outFailure.message = "Capture range is empty (end <= start)."; return nullptr; } ReaProject* proj = EnumProjects(-1, nullptr, 0); if (!proj) { outFailure.status = CaptureStatus::NoProject; outFailure.message = "No active project."; return nullptr; } // Refuse if the transport is already playing/recording — we own the transport for // the capture window and must not hijack a user's live take. if (GetPlayStateEx(proj) & (1 | 4)) { outFailure.status = CaptureStatus::TransportBusy; outFailure.message = "Transport is already playing/recording — realtime capture " "refused. Stop the transport first."; return nullptr; } // Saved-project gate (same as offline): the bank folder resolves against the // .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved. std::string rppPath = readRppPath(); if (rppPath.empty()) { Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify) rppPath = readRppPath(); } if (rppPath.empty()) { outFailure.status = CaptureStatus::NoProject; outFailure.message = "Project must be saved before capture — nothing captured."; return nullptr; } const std::string projectDir = normSlashes(std::filesystem::path(rppPath).parent_path().string()); // --- Build the in-flight state (owns the snapshot + teardown) --------------- RealtimeCaptureHandle st(new RealtimeCaptureState()); st->proj_ = proj; st->request_ = request; st->uniqueTag_ = makeUniqueTag(); st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_); // The recorded window end: extended past the range end for a tail mode (Auto/Manual), // exact for None. This — not request.endSeconds — is what the completion machine // waits for; the extra window past the range end is trimmed later (Auto) or kept // (Manual). Pure mapping (render_settings), shared caps with the offline tail. st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode, request.endSeconds, request.tailMs); // DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT // wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view // shells is intentional. This backend fully restores its own state across every // terminal path (the restore() latch); an undo point would surface an internal, // fully-reversed scaffold in the user's undo history for no user-meaningful action. // Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp // track exists (so it is never in the arm snapshot and keeps the arm we set). st->snapshotAndDisarmOthers(); // Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from // both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring // invariant — it would otherwise double the tapped tracks in the user's monitoring). const int idx = CountTracks(proj); InsertTrackAtIndex(idx, false); st->temp_ = GetTrack(proj, idx); if (!st->temp_) { outFailure.status = CaptureStatus::RenderFailed; outFailure.message = "Could not create the hidden temp record track."; st->restore(); // undo the disarm + cursor/time-sel snapshot return nullptr; } SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0); // Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp // track (CreateTrackSend(source, temp)). The temp records its OWN output, so the // sends' outputs SUM in it — multiple selected tracks are captured together (same as // offline track scope). See §TAP for why this faithfully captures each track's own // output and needs no FxBypassGuard. // // Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default, // (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and // fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at // defaults deliberately — that IS the track-scope tap point. // // DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record // reproduces the track's own output sample-for-sample (latency comp, pan law, // mono/stereo folding) is the crux to verify live. int sendsMade = 0; for (MediaTrack* src : sourceTracks) { if (!src || src == st->temp_) continue; if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade; } if (sendsMade == 0) { // Every send failed (should not happen for valid selected tracks). Refuse // rather than record a guaranteed-silent file. outFailure.status = CaptureStatus::RenderFailed; outFailure.message = "Could not route any selected track into the record tap — " "nothing to capture."; st->restore(); // deleting the temp track drops any partial sends too return nullptr; } // Record-mode values from the pure planner. The temp track records its OWN output; // it has no FX and unity fader, so its post-fader output equals the summed sends. // Track scope is fully wet -> PostFader. (The actual track-scope tap point is the // source sends' default post-fader mode; the temp's recmode only records the sum.) const OutputTap tap = outputTapForWetDry(request.wetDry); const RecordModePlan rec = recordModePlanFor(request.channelCount, tap); SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast(rec.recMode)); SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS", static_cast(rec.recModeFlags)); SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring // Record range: time selection over [start, recordWindowEnd], play cursor at start. // recordWindowEnd extends past the request's range end for a tail mode so the // transport captures the decaying tail; it equals the range end for None (exact // bounds). Both cursor + time selection were snapshotted and are restored by // restore(). double rs = request.startSeconds, re = st->recordWindowEnd_; GetSet_LoopTimeRange(true, false, &rs, &re, false); SetEditCurPos(request.startSeconds, false, false); // Start the transport and RETURN. tick() drives the rest across timer ticks. // // DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact // range/auto-punch/stop behavior depends on the user's transport settings — not // header-guaranteed. tick() detects completion via the play cursor reaching the // range end (the pure state machine), independent of REAPER's auto-punch. CSurf_OnRecord(); // Anchor the wall-clock safety ceiling from here (steady clock — independent of the // play cursor, so a transport that starts but never advances is still bounded). st->markElapsedStart(); return st; } // ============================================================================ // tick — advance the in-flight record; on terminal, finalize/abort + restore // ============================================================================ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { RealtimeTickResult out; // If a prior terminal path already tore this down (e.g. abort() then a stray // tick), do nothing — the state is spent. if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } const RecordPhase prevPhase = state.phase_; // Read the transport bound to the record's OWN project (a project switch cannot // point these reads at the wrong transport). &4 = recording. Gather everything the // pure machine needs (transport + wall-clock ceilings + file-flush readiness). RecordTickInputs inputs; inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0; inputs.transport.playPosition = GetPlayPositionEx(state.proj_); inputs.elapsedSeconds = state.elapsedSeconds(); // Deferred-finalize flush check (review §2), only meaningful once stopped. The // recorded file is READY when its size is a valid positive value AND unchanged // from the previous tick — REAPER finished flushing/closing the take on the audio // thread. Comparing across a tick avoids moving a file mid-write (truncated take). if (prevPhase == RecordPhase::Finalizing) { state.markFinalizingStartOnce(); inputs.finalizingSeconds = state.finalizingSeconds(); const std::int64_t sz = recordedFileSize(state.temp_); inputs.fileReady = (sz > 0 && sz == state.lastFileSize_); state.lastFileSize_ = sz; } // Wait for the transport to reach the RECORDED window end (extended past the // range end for a tail mode), not the request's range end — the extra tail window // is part of the record. The record safety ceiling scales with it (window - start // + margin) inside the pure machine. state.phase_ = advanceRecordPhase(state.phase_, inputs, state.request_.startSeconds, state.recordWindowEnd_); // On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER // begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_)) // — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a // foreign one during a project switch), not the record's own. The flush wait then // proceeds across subsequent ticks before the file is moved. if (prevPhase == RecordPhase::Recording && isStopRequested(state.phase_)) { state.stopOwnTransport(); state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop } if (!isTerminalPhase(state.phase_)) { out.status = RealtimeTickStatus::InProgress; return out; // keep the OnTimer tick fast — recording or flushing } // Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done, // finalize moves the now-stable file into the bank + builds the Sample. On Failed // (the flush timeout) there is nothing usable — report RenderFailed. Then restore // ALL snapshotted state — the non-destructive gate, idempotent + unconditional. CaptureResult res; if (state.phase_ == RecordPhase::Done) { res = finalizeRecording(state); } else { res.status = CaptureStatus::RenderFailed; res.message = "Realtime record timed out waiting for the recorded file to " "flush/close (nothing captured)."; } state.markFinalized(); state.restore(); out.result = res; out.status = (res.status == CaptureStatus::Ok) ? RealtimeTickStatus::Done : RealtimeTickStatus::Failed; return out; } // ============================================================================ // abort — force-terminate now (shutdown / project switch) + restore // ============================================================================ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { RealtimeTickResult out; // Already torn down (idempotent): report Failed and leave it. if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } // CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ / // temp_ point at freed memory. The closed project already reclaimed its // temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER // state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those // freed pointers is the use-after-free bug this guard exists to prevent. This is // the ONE terminal path that can run against a possibly-closed project (tick() only // runs while proj_ is the active — hence still-open — project); guarding here covers // both the project-switch and unload callers. if (!state.captureProjectStillOpen()) { state.dropWithoutRestore(); out.result.status = CaptureStatus::RenderFailed; out.result.message = "Realtime capture dropped — the captured project was closed " "mid-record (nothing to restore; no capture persisted)."; out.status = RealtimeTickStatus::Failed; return out; } // The project is still open (a tab-switch, or a clean unload with the project // present): stop the transport, then TRY to finalize whatever was captured so a // near-complete record still keeps the audio; if nothing was recorded (or the file // has not flushed yet), finalize returns RenderFailed and we abort clean. // Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no // longer active, so the global CSurf_OnStop would stop the wrong (foreign) project. // // NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload / // switch); it cannot span ticks to wait for the flush the way tick() does, so its // finalize still races REAPER's audio-thread take close. That is inherent to a // best-effort terminal grab and is acceptable — the normal completion path (tick) // is the one that must be flush-safe. state.stopOwnTransport(); CaptureResult res = finalizeRecording(state); state.markFinalized(); state.restore(); // the non-destructive gate — always runs out.result = res; out.status = (res.status == CaptureStatus::Ok) ? RealtimeTickStatus::Done : RealtimeTickStatus::Failed; return out; } } // namespace reasampler