refactor(capture): drop master scope; realtime taps selected track

Capture is now item + track only (master removed as a scope; still bypassed
as out-of-scope chain). Realtime records the selected track's own output via
per-track post-fader sends into a hidden temp track, fixing the silent file.
This commit is contained in:
2026-07-23 16:34:02 -04:00
parent 3791e6c119
commit 4ef41cf705
6 changed files with 215 additions and 192 deletions
+92 -54
View File
@@ -6,9 +6,9 @@
//
// 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.
// 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")
@@ -18,7 +18,7 @@
// 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.
// 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.
@@ -33,25 +33,39 @@
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
//
// ============================================================================
// §FORK — wet-master / per-scope routing (SURFACED, NOT SILENTLY BUILT)
// §TAP — track-output tap (selected track's own output, PRE-parent)
// ============================================================================
// 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.
// 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.
//
// 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.
// 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.
//
// 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.
// 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 "capture.h"
@@ -70,7 +84,6 @@
#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
@@ -151,8 +164,8 @@ std::int64_t recordedFileSize(MediaTrack* temp) {
// ============================================================================
// 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),
// 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 {
@@ -165,9 +178,11 @@ public:
BankPaths paths_;
std::string uniqueTag_;
// The transient sink + the send we made from the master into it.
// 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;
MediaTrack* master_ = nullptr;
// The record phase (pure state machine drives the transition). Starts Recording.
RecordPhase phase_ = RecordPhase::Recording;
@@ -229,7 +244,7 @@ public:
// 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),
// 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
@@ -242,7 +257,7 @@ public:
}
// Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured
// project was CLOSED mid-record, proj_/temp_/master_ point at freed memory;
// 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
@@ -259,7 +274,6 @@ public:
void dropWithoutRestore() {
restored_ = true;
temp_ = nullptr;
master_ = nullptr;
armSnaps_.clear();
}
@@ -271,8 +285,9 @@ public:
// 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).
// 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.
@@ -367,12 +382,24 @@ void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noex
}
RealtimeCaptureHandle
RealtimeRecordBackend::begin(const CaptureRequest& request, CaptureResult& outFailure) {
// Only the master scope is implemented this increment (see §FORK).
if (request.sourceMode != SourceMode::MasterMix) {
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.
if (request.sourceMode != SourceMode::SelectedTracks) {
outFailure.status = CaptureStatus::UnsupportedMode;
outFailure.message = "RealtimeRecordBackend implements MASTER scope only this "
"increment (track/item realtime routing is a surfaced fork).";
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;
}
@@ -431,8 +458,8 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, CaptureResult& outFa
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).
// 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);
@@ -446,28 +473,39 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, CaptureResult& outFa
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.
// 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.
//
// 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;
// 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;
}
const int sendIdx = CreateTrackSend(st->master_, st->temp_);
if (sendIdx < 0) {
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 the master output into the record track.";
st->restore(); // deleting the temp track drops any partial send too
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. Master mix is fully wet -> PostFader.
// 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<double>(rec.recMode));
@@ -581,7 +619,7 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
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_ 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