feat(capture): M8 async realtime-record backend (master scope)

Timer-driven realtime capture behind ICaptureBackend (begin/tick/abort via
OnTimer, non-blocking). Records master into a hidden temp track, moved to the
bank non-destructively with idempotent restore across every terminal path.
Pure phase machine unit-tested. Track/item deferred; realtime is non-deterministic.
This commit is contained in:
2026-07-23 15:16:36 -04:00
parent e1b2c0ba1b
commit bd187d4f89
7 changed files with 1672 additions and 9 deletions
+625
View File
@@ -0,0 +1,625 @@
// 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 MASTER
// scope only (records the master-mix output). Track/Item scopes are a genuine
// routing fork (see §FORK below) and are refused 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 master send, 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.
//
// ============================================================================
// §FORK — wet-master / per-scope routing (SURFACED, NOT SILENTLY BUILT)
// ============================================================================
// The open design question (CONTEXT.md / PLAN.md "realtime wet-master routing"):
// tap the SCOPED output into the hidden record track WITHOUT altering the user's
// monitoring, with correct latency compensation.
//
// This increment resolves it for MASTER scope with the cleanest header-verifiable
// recipe: a temp track carrying a SEND from the master track, recorded in
// output-record mode (I_RECMODE = stereo/mono-out w/latency comp). The temp
// track's own B_MAINSEND is cleared (it does NOT sum back into the master), so the
// user hears no change or double — the record tap is a pure branch off the master
// bus. Latency compensation is REAPER's (I_RECMODE 3/6 are the *latency-compensated*
// output modes), so the recorded file lines up with the source.
//
// Track/Item scopes DO NOT compose cleanly with this recipe (they need per-scope
// source-track routing + the send-isolation rule) — that is the fork the brief says
// to STOP before, and they are refused with UnsupportedMode. FxBypassGuard is NOT
// reused here — it alters live monitoring (wrong tool for realtime); the master-send
// recipe needs no chain neutralization.
#include "capture.h"
#include <chrono>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <string>
#include <vector>
#include "capture_paths.h"
#include "realtime_record.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_Master_GetTempo
#define REAPERAPI_WANT_GetSetProjectInfo
#define REAPERAPI_WANT_GetMasterTrack
#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<long long>(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<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(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<char> buf(4096, '\0');
GetMediaSourceFileName(src, buf.data(), static_cast<int>(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<std::int64_t>(sz);
}
} // namespace
// ============================================================================
// RealtimeCaptureState — the in-flight snapshot + idempotent restore
// ============================================================================
// Holds EVERYTHING to restore across the many ticks the record spans (temp track,
// other tracks' I_RECARM, master send, 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 transient sink + the send we made from the master into it.
MediaTrack* temp_ = nullptr;
MediaTrack* master_ = 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<double>(
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<double>(
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<ArmSnap> 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 send + the recorded arrange 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_/master_ 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;
master_ = 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 master send AND the recorded arrange
// item in one move — nothing stays in the arrange (load-bearing principle).
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 {
// 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
}
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<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
cap.captureTempo = Master_GetTempo();
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
result.status = CaptureStatus::Ok;
result.sample = sampleFromRecordedCapture(cap);
result.message = "Realtime-captured [" +
std::to_string(st.request_.startSeconds) + "s, " +
std::to_string(st.request_.endSeconds) + "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, CaptureResult& outFailure) {
// Only the master scope is implemented this increment (see §FORK).
if (request.sourceMode != SourceMode::MasterMix) {
outFailure.status = CaptureStatus::UnsupportedMode;
outFailure.message = "RealtimeRecordBackend implements MASTER scope only this "
"increment (track/item realtime routing is a surfaced fork).";
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_);
// 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 — the record tap is a pure branch off the master bus).
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 MASTER output into the temp track (a send master -> temp). The temp
// track records this in output-record mode.
//
// DAW-ONLY ASSUMPTION (flag): whether output-record mode (I_RECMODE 3/6) on a
// track fed only by a master send records THAT send's signal is the crux to
// verify live — named here so DAW testing targets it directly.
st->master_ = GetMasterTrack(proj);
if (!st->master_) {
outFailure.status = CaptureStatus::RenderFailed;
outFailure.message = "Could not resolve the master track for realtime routing.";
st->restore(); // temp track removed here
return nullptr;
}
const int sendIdx = CreateTrackSend(st->master_, st->temp_);
if (sendIdx < 0) {
outFailure.status = CaptureStatus::RenderFailed;
outFailure.message = "Could not route the master output into the record track.";
st->restore(); // deleting the temp track drops any partial send too
return nullptr;
}
// Record-mode values from the pure planner. Master mix 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<double>(rec.recMode));
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS",
static_cast<double>(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,end], play cursor at start. Both were
// snapshotted and will be restored by restore().
double rs = request.startSeconds, re = request.endSeconds;
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;
}
state.phase_ = advanceRecordPhase(state.phase_, inputs,
state.request_.startSeconds,
state.request_.endSeconds);
// 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_ / master_ 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