// REAPER-facing realtime-record backend (RealtimeRecordBackend): the async record // lifecycle — state snapshot/restore + begin/tick/abort. The file-side half // (recorded-file discovery, move-into-bank, Auto-tail trim, Sample population) // lives in capture_realtime_finalize.cpp. // // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is // the one TU that defines the API pointers; here they are extern. // // Captures the requested scope by recording in realtime into a hidden temp // track, then moves the recorded file into the bank as a Sample — // non-destructively. TRACK scope only this increment (the selected track's own // output); item realtime is deferred (UnsupportedMode) rather than half-built. // // ASYNC: a realtime record takes (end - start) wall-clock seconds; blocking the // main thread for that long freezes REAPER's UI. So it's driven across timer // ticks: begin() validates, snapshots all state to restore, creates the temp // track, routes the source-track tap, arms, CSurf_OnRecord, and returns // immediately; tick() (from OnTimer, same tick as session.poll()) reads the // transport and on a terminal verdict stops + finalizes/aborts + restores // everything; abort() force-terminates (shutdown/project switch) + restores. // // The snapshot + restore live on RealtimeCaptureState, not a function-scope RAII // guard, because the record spans ticks — no single stack frame outlives it. // restore() is idempotent: every terminal path (completion, user stop, error, // project switch, unload) funnels through the same restore. The pure record-mode // bookkeeping, recorded-file->Sample mapping, and completion state machine // (advanceRecordPhase) live in core/capture/capture_realtime (unit-tested // outside the DAW); this TU owns only the REAPER-bound lifecycle recipe. // // TAP: the hidden temp track receives a send FROM each selected source track // (CreateTrackSend(source, temp)) and records its own output (B_MAINSEND=0, so // it never sums back into the master — no feedback, no monitoring double). // Multiple selected tracks sum in the one temp track — a real mix, which is why // realtime accepts a multi-track selection where the offline track scope refuses // it (that render source cannot express a sum; see shell/capture/CLAUDE.md). // // Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0 // (post-fader), which taps the source track after its own FX/fader/pan — its // own output — but before the parent/folder/master sums it. The tap is // chain-independent by construction: there's nothing downstream of the branch // point to neutralize. (An earlier spike sent FROM the master, which REAPER // refuses as a feedback loop and silently recorded nothing — a regular // track->track send has no such loop.) // // Non-destructive: deleting the temp track on teardown removes every send // created into it (REAPER cannot leave a send dangling to a deleted // destination), so no source track retains any routing change. #include "shell/capture/capture_realtime_shell.h" #include #include #include #include #include #include "core/capture/capture_paths.h" // deriveBankPaths #include "core/capture/capture_realtime.h" // RecordPhase machine, record-mode plan (pure) #include "core/capture/render_settings.h" // realtimeRecordWindowEnd #include "shell/capture/capture_realtime_finalize.h" // recordedFilePath, finalizeRecording #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_Main_SaveProject #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_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::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; } // 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()); } // -1 if unresolved yet. Used by the flush wait to detect stability (size // unchanged across a tick) before moving the file — a take REAPER is still // flushing 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 // Holds everything to restore across the many ticks the record spans (temp // track + its sends, other tracks' I_RECARM, transport, edit cursor, time // selection), plus the request echo needed to finalize the Sample. restore() // is idempotent (restored_ latch) — the single teardown every terminal path calls. class RealtimeCaptureState { public: // Transport reads use *Ex(proj_) so a project switch mid-record can't read // the wrong transport. ReaProject* proj_ = nullptr; CaptureRequest request_; BankPaths paths_; std::string uniqueTag_; // Project seconds, >= request_.endSeconds. A tail mode runs the transport // past the range end (Auto: +8s cap; Manual: +set length) — this, not // request_.endSeconds, is what the completion machine waits for. double recordWindowEnd_ = 0.0; // Sends created into temp_ are removed automatically when temp_ is // deleted — no separate send handle to track. MediaTrack* temp_ = nullptr; RecordPhase phase_ = RecordPhase::Recording; // Steady clock (not the play cursor) so a stuck/looping transport is still // caught. begunAt_ set at begin(); finalizingAt_ set on the Recording-> // Finalizing edge 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); } } } // Idempotent teardown called on every terminal path: stop transport if // still running, delete temp track (drops its sends + recorded item), // restore other tracks' arm, restore time selection + edit cursor. // OnStopButtonEx(proj_) is project-scoped, not the global CSurf_OnStop, so // a project switch mid-record (proj_ no longer active) still stops OUR // project's transport, never the foreign now-active one. void stopOwnTransport() { if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_); } // If the captured project was closed mid-record, proj_/temp_ point at freed // memory; touching them is a use-after-free. ValidatePtr2 with a null // project validates the ReaProject* itself. Every teardown that // dereferences a captured REAPER object must gate on this first. bool captureProjectStillOpen() const { return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*"); } // For the closed-project case: a closed project already reclaimed its temp // track, arms, and transport, so drop the handle without touching REAPER state. void dropWithoutRestore() { restored_ = true; temp_ = nullptr; armSnaps_.clear(); } void restore() { if (restored_) return; restored_ = true; stopOwnTransport(); // Deleting the temp track drops the source-track sends (REAPER removes // every send whose destination is deleted) and the recorded item in one move. if (temp_) { DeleteTrack(temp_); temp_ = nullptr; } for (const ArmSnap& s : armSnaps_) SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm); armSnaps_.clear(); 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; }; 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) { // Item realtime is deferred — 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); 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()); RealtimeCaptureHandle st(new RealtimeCaptureState()); st->proj_ = proj; st->request_ = request; st->uniqueTag_ = makeUniqueTag("rt-"); st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_); // Extended past the range end for a tail mode (Auto/Manual), exact for // None; the extra window is trimmed later (Auto) or kept (Manual). st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode, request.endSeconds, request.tailMs); // Deliberately NOT wrapped in an undo block — this backend fully restores // its own state across every terminal path, so an undo point would surface // an internal, fully-reversed scaffold for no user-meaningful action. // Disarm every other track BEFORE the temp track exists so it's never in // the arm snapshot. st->snapshotAndDisarmOthers(); // Hidden temp track: no default FX/envelopes, hidden from both panels, // B_MAINSEND=0 so it doesn't sum back into the master (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(); 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); // A send FROM each selected source track INTO the temp track; the temp // records its own output, so sends sum in it — matching offline track // scope's multi-track handling. Sends default to post-fader/full-stereo, // left at defaults deliberately — that IS the track-scope tap point. int sendsMade = 0; for (MediaTrack* src : sourceTracks) { if (!src || src == st->temp_) continue; if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade; } if (sendsMade == 0) { 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; } // The temp track has no FX and unity fader, so its post-fader output // equals the summed sends; track scope is fully wet -> PostFader. 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 // recordWindowEnd extends past the range end for a tail mode so the // transport captures the decay; cursor + time selection are restored by restore(). double rs = request.startSeconds, re = st->recordWindowEnd_; GetSet_LoopTimeRange(true, false, &rs, &re, false); SetEditCurPos(request.startSeconds, false, false); // tick() detects completion via the play cursor reaching the range end // (the pure state machine), independent of REAPER's auto-punch settings. CSurf_OnRecord(); // Steady clock, independent of the play cursor, so a transport that starts // but never advances is still bounded. st->markElapsedStart(); return st; } RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { RealtimeTickResult out; // A prior terminal path (e.g. abort()) already tore this down — spent. if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } const RecordPhase prevPhase = state.phase_; // *Ex(state.proj_) so a project switch can't point these reads at the // wrong transport. RecordTickInputs inputs; inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0; inputs.transport.playPosition = GetPlayPositionEx(state.proj_); inputs.elapsedSeconds = state.elapsedSeconds(); // File is ready when its size is positive and unchanged from the previous // tick — REAPER finished flushing the take. Comparing across a tick avoids // moving a file mid-write. 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; } // Waits for the transport to reach the recorded window end (extended for a // tail mode), not the request's range end — the extra tail window is part // of the record. state.phase_ = advanceRecordPhase(state.phase_, inputs, state.request_.startSeconds, state.recordWindowEnd_); // On Recording -> Finalizing, stop OUR project's transport once so REAPER // begins flushing the take; project-scoped so a project switch can't stop // the wrong (foreign active) project. if (prevPhase == RecordPhase::Recording && isStopRequested(state.phase_)) { state.stopOwnTransport(); state.markFinalizingStartOnce(); } if (!isTerminalPhase(state.phase_)) { out.status = RealtimeTickStatus::InProgress; return out; } // Done: file flushed + stable, finalize moves it into the bank. Failed: // flush ceiling tripped, nothing usable. CaptureResult res; if (state.phase_ == RecordPhase::Done) { res = finalizeRecording(state.proj_, state.temp_, state.request_, state.paths_, state.uniqueTag_, state.recordWindowEnd_); } 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; } RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { RealtimeTickResult out; if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } // If the captured project was closed mid-record, proj_/temp_ point at // freed memory — drop the handle without touching REAPER state. This is // the one terminal path that can run against a possibly-closed project // (tick() only runs while proj_ is still the active project). 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; } // Project still open: stop the transport, then try to finalize whatever // was captured so a near-complete record keeps its audio; if nothing // usable was recorded, finalize returns RenderFailed and we abort clean. // abort() is the force-terminate path — unlike tick() it can't span ticks // to wait for the flush, so it still races REAPER's audio-thread take close. state.stopOwnTransport(); CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_, state.paths_, state.uniqueTag_, state.recordWindowEnd_); state.markFinalized(); state.restore(); out.result = res; out.status = (res.status == CaptureStatus::Ok) ? RealtimeTickStatus::Done : RealtimeTickStatus::Failed; return out; } } // namespace reasampler::capture