Cut shell/capture comment bloat ~33% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:48:59 -04:00
parent 1f24c4b095
commit 54f5f24506
22 changed files with 699 additions and 1215 deletions
+123 -237
View File
@@ -1,78 +1,49 @@
// capture_realtime_shell.cpp — REAPER-facing realtime-record backend
// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore +
// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming
// rider: the PURE module owns the capture_realtime stem, this shell takes the
// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file
// discovery, move-into-bank, Auto-tail trim, Sample population — lives in
// capture_realtime_finalize.cpp (T4-08 split).
// 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.
//
// 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).
// 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 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.
// 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 — 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.
// 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 (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 the pure
// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU
// owns only the REAPER-bound lifecycle recipe.
// 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 — 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.
// 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, 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.
// 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.)
//
// 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.
// 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"
@@ -125,10 +96,9 @@ std::string readRppPath() {
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.
// -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;
@@ -140,42 +110,33 @@ std::int64_t recordedFileSize(MediaTrack* temp) {
} // 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.
// 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:
// 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.
// 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_;
// 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).
// 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;
// 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.
// Sends created into temp_ are removed automatically when temp_ is
// deleted — no separate send handle to track.
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.
// 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_{};
@@ -225,37 +186,26 @@ public:
}
}
// 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).
// 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_);
}
// 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.
// 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*");
}
// 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.
// 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;
@@ -266,21 +216,16 @@ public:
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).
// 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; }
// 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);
}
@@ -294,13 +239,6 @@ private:
bool finalized_ = false;
};
// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the
// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in
// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle.
// ============================================================================
// 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
}
@@ -309,8 +247,8 @@ RealtimeCaptureHandle
RealtimeRecordBackend::begin(const CaptureRequest& request,
const std::vector<MediaTrack*>& 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.
// 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 "
@@ -354,7 +292,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
// .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)
Main_SaveProject(proj, true);
rppPath = readRppPath();
}
if (rppPath.empty()) {
@@ -365,68 +303,51 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
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("rt-"); // shared mint (T1-11 monotonic counter)
st->uniqueTag_ = makeUniqueTag("rt-");
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.
// 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);
// 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).
// 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 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).
// 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(); // undo the disarm + cursor/time-sel snapshot
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);
// 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.
// 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) {
// 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.";
@@ -434,10 +355,8 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
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.)
// 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<double>(rec.recMode));
@@ -446,54 +365,41 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
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().
// 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);
// 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.
// tick() detects completion via the play cursor reaching the range end
// (the pure state machine), independent of REAPER's auto-punch settings.
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).
// 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.
// 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_;
// 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).
// *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();
// 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).
// 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();
@@ -502,34 +408,29 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
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.
// 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 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.
// 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(); // anchor the flush ceiling from the stop
state.markFinalizingStartOnce();
}
if (!isTerminalPhase(state.phase_)) {
out.status = RealtimeTickStatus::InProgress;
return out; // keep the OnTimer tick fast — recording or flushing
return out;
}
// 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.
// 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_,
@@ -550,23 +451,15 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
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 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;
@@ -576,25 +469,18 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
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.
// 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(); // the non-destructive gate — always runs
state.restore();
out.result = res;
out.status = (res.status == CaptureStatus::Ok)