diff --git a/src/capture.h b/src/capture.h index 770fb3b..4d43846 100644 --- a/src/capture.h +++ b/src/capture.h @@ -21,6 +21,12 @@ #include "bank_model.h" +// MediaTrack is forward-declared (like track_guid.h) so this header stays +// REAPER-free while RealtimeRecordBackend::begin can take the resolved source +// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a +// pure/header context; only the REAPER-facing capture_realtime.cpp touches them. +class MediaTrack; + namespace reasampler { // Audio bit-depth for the rendered wav. 32-bit float is the M3 default — @@ -150,8 +156,9 @@ struct RealtimeTickResult { }; // The opaque in-flight capture state. Owns the snapshot of everything to restore -// (temp track, other tracks' I_RECARM, master send/routing, transport, edit cursor, -// time selection) and the record's own project handle. Defined in +// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, +// transport, edit cursor, time selection) and the record's own project handle. +// Defined in // capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject* // leaks here) by holding it behind a forward-declared type + unique_ptr. // @@ -182,21 +189,25 @@ using RealtimeCaptureHandle = // harder here than offline because the record spans ticks: the snapshot + restore // live on RealtimeCaptureState, not a function-scope RAII destructor. // -// SCOPE (this increment): MASTER scope only — records the master-mix output, which -// does not need the FxBypassGuard scope isolation (the whole chain is in scope). -// Track/Item scopes are a genuine routing fork surfaced to Daniel, NOT silently -// built (see capture_realtime.cpp §FORK). A Track/Item request is refused. +// SCOPE (this increment): TRACK scope only — records the selected track's OWN +// output (item + that track's own FX + its own fader/pan, PRE-parent), matching +// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's +// output is naturally PRE-parent (the parent has not summed it yet), so the tap is +// chain-independent by construction. Item realtime is deferred (UnsupportedMode). class RealtimeRecordBackend { public: - // Starts a realtime record: validates the request (master scope, non-empty - // range, active + saved project, transport idle), snapshots all state to - // restore, creates the hidden temp track, routes the master send, arms, and - // CSurf_OnRecord — then returns IMMEDIATELY (no wait, no UI block). On success - // the returned unique_ptr owns the in-flight state; drive it with tick(). On a - // validation/setup failure returns nullptr and fills `outFailure` with the - // CaptureStatus + message (nothing was left mutated — begin() restores on its - // own failure paths). + // Starts a realtime record: validates the request (track scope, non-empty range, + // at least one source track, active + saved project, transport idle), snapshots + // all state to restore, creates the hidden temp track, routes a send FROM each + // source track INTO the temp track, arms, and CSurf_OnRecord — then returns + // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to + // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, + // carrying only the provenance GUIDs). On success the returned unique_ptr owns the + // in-flight state; drive it with tick(). On a validation/setup failure returns + // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was + // left mutated — begin() restores on its own failure paths). RealtimeCaptureHandle begin(const CaptureRequest& request, + const std::vector& sourceTracks, CaptureResult& outFailure); // Advances the in-flight record one tick. Reads the transport (bound to the diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp index 633b3d3..d20290f 100644 --- a/src/capture_realtime.cpp +++ b/src/capture_realtime.cpp @@ -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& 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(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 diff --git a/src/main.cpp b/src/main.cpp index bca7482..bd126fc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,7 +35,7 @@ // Persistent action-id prefix for the ReaSampler action family. // Every bindable action (capture / insert / slot / verify) mints its command id -// from a string beginning with this prefix, e.g. "CEREBELLUM_REASAMPLER_CAPTURE_MASTER". +// from a string beginning with this prefix, e.g. "CEREBELLUM_REASAMPLER_CAPTURE_TRACK". // FOREVER-STABLE once shipped: user keybindings key off these strings, so the // prefix and any minted id must never change after release. #define REASAMPLER_ACTION_PREFIX "CEREBELLUM_REASAMPLER_" @@ -44,17 +44,18 @@ REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct -// ---- Capture action family (three FX scopes) ------------------------------- -// Three bindable SCOPE actions from captureActionTable() (render_settings, pure): -// capture item / track / master. Each infers its range (razor-else-time) and -// enforces the FX-scope invariant via FX-bypass-around-render (FxBypassGuard): +// ---- Capture action family (two FX scopes) --------------------------------- +// Two bindable SCOPE actions from captureActionTable() (render_settings, pure): +// capture item / track. Each infers its range (razor-else-time) and enforces the +// FX-scope invariant via FX-bypass-around-render (FxBypassGuard): // Item -> take/item FX only (bypass the item's track + ancestors + master). // Track -> item FX + track's own FX (bypass ancestors + master). -// Master -> whole chain (bypass nothing). -// This REPLACES the retired M7 four-mode family (master / tracks / items / razor). -// The retired CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are -// mirror-unregistered on unload so old keybindings clear cleanly; CAPTURE_MASTER's -// id string is preserved. +// There is NO master scope — to capture the master you render a track. (The master +// track's FX/gain/pan are STILL neutralized for both scopes as the out-of-scope +// chain — master is a bypass target, not a capture scope.) The retired M7 +// CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids AND the removed +// CAPTURE_MASTER / CAPTURE_MASTER_REALTIME ids are mirror-unregistered on unload so +// old keybindings clear cleanly. // // The minted command ids parallel the table rows 1:1 (same index). gaccel storage // must outlive registration (REAPER holds each pointer), so both vectors are file- @@ -62,14 +63,18 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct static std::vector g_captureCmdIds; static std::vector g_captureAccels; -// Retired capture-action command-id strings (M7 four-mode family). Kept ONLY to -// mirror-unregister them on unload so a user's stale keybindings are cleaned up. -// Never re-register these. CAPTURE_MASTER is NOT here — its id string carries over -// to the new master scope action unchanged. +// Retired capture-action command-id strings. Kept ONLY to mirror-unregister them on +// unload so a user's stale keybindings are cleaned up. Never re-register these. +// * The M7 four-mode ids (tracks/items/razor WET). +// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the +// master realtime action are REMOVED (capture is now item + track only; realtime +// taps the selected track). Their shipped ids are retired so old keybindings clear. static const char* const kRetiredCaptureCmdStrings[] = { "CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET", + "CEREBELLUM_REASAMPLER_CAPTURE_MASTER", + "CEREBELLUM_REASAMPLER_CAPTURE_MASTER_REALTIME", }; // Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. @@ -85,12 +90,13 @@ static int g_cmdToggleBankPanel = 0; static int g_cmdInsertSelected = 0; static int g_cmdInsertSelectedConform = 0; -// Command id for the M8 "capture master (realtime)" action. FOREVER-STABLE string. -// Records the master output in realtime (transport-driven) into a hidden temp track -// via RealtimeRecordBackend, then moves the recorded file into the bank. The -// realtime SIBLING of the offline CAPTURE_MASTER scope action: same range logic -// (razor-else-time), same bank/persist path, different backend. Dialog-free. -static int g_cmdCaptureMasterRealtime = 0; +// Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE +// string. Records the selected track's OWN output in realtime (transport-driven) into +// a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the +// bank. The realtime SIBLING of the offline CAPTURE_TRACK scope action: same range +// logic (razor-else-time), same track selection, same bank/persist path, different +// backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.) +static int g_cmdCaptureTrackRealtime = 0; // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. // Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user @@ -223,12 +229,13 @@ static void OnTimer() // range); the caller reports it and writes nothing. // The resolved source: exact bounds + the source tracks (for FX-bypass + Sample -// provenance GUIDs). `sourceTracks` is empty for Master scope. +// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the +// selected tracks (Track scope). struct ResolvedSource { double startSeconds = 0.0; double endSeconds = 0.0; - std::vector sourceTracks; // item's/selected tracks; empty for master + std::vector sourceTracks; // item-owning tracks / selected tracks std::vector trackGuids; // canonical GUIDs of sourceTracks }; @@ -325,8 +332,8 @@ static bool collectSelectedItemTracks(ResolvedSource& out) return !out.sourceTracks.empty(); } -// Resolves the source for a scope: the selection tracks (item/track) or none -// (master), plus the inferred range. Returns false with a reason on nothing to do. +// Resolves the source for a scope: the selection tracks (item/track), plus the +// inferred range. Returns false with a reason on nothing to do. static bool ResolveScopeSource(reasampler::CaptureScope scope, ResolvedSource& out, std::string& why) { @@ -343,8 +350,6 @@ static bool ResolveScopeSource(reasampler::CaptureScope scope, why = "select at least one track"; return false; } break; - case CaptureScope::Master: - break; // whole chain — no source-track collection } return resolveRange(out.startSeconds, out.endSeconds, why); } @@ -359,7 +364,7 @@ static bool ResolveScopeSource(reasampler::CaptureScope scope, // neutralize set is IDENTICAL to the FX-bypass set: // Item -> own track + all ancestors + master (take vol/pan kept: item content). // Track -> all ancestors + master (selected track's OWN vol/pan kept). -// Master-> nothing (full chain, unchanged). +// (Master is a bypass TARGET for both scopes — never a scope of its own.) // // Per track in that set we snapshot & set the full parent-chain-independence set, // so a Track/Item capture is uncolored by the parent/folder/master it renders @@ -545,22 +550,23 @@ static void RunCapture(const reasampler::CaptureActionDef& def) ShowConsoleMsg(log.c_str()); } -// STARTS the M8 REALTIME master capture and returns immediately — the record runs -// across timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Infers -// the range (razor-else-time, the same orthogonal range logic as the offline scopes) -// and starts recording the master output into a hidden temp track via -// RealtimeRecordBackend::begin; OnTimer drives it to completion, then adds the Sample -// and persists. MASTER scope only this increment (track/item realtime routing is a -// surfaced fork — see capture_realtime.cpp §FORK). Dialog-free. Non-bit-identical by -// nature (it is realtime) — offline stays the deterministic default. FxBypassGuard is -// NOT used here (it neutralizes the live chain, altering the user's monitoring). The +// STARTS the REALTIME track capture and returns immediately — the record runs across +// timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the +// selected tracks + the range (razor-else-time, the same orthogonal range logic as the +// offline scopes) and starts recording each selected track's OWN output into a hidden +// temp track via RealtimeRecordBackend::begin (a send FROM each source track INTO the +// temp — see capture_realtime.cpp §TAP); OnTimer drives it to completion, then adds the +// Sample and persists. TRACK scope only this increment (item realtime is deferred). +// Dialog-free. Non-bit-identical by nature (it is realtime) — offline stays the +// deterministic default. FxBypassGuard is NOT used here — the track-output tap is +// PRE-parent by construction (§TAP), so there is no live chain to neutralize. The // load-bearing principle holds structurally — this writes a file + a bank entry ONLY; // the temp track is a transient sink removed by the backend, nothing lands in arrange. // // A SECOND realtime capture requested while one is in progress is REJECTED — the // first keeps running (we own the transport for its window; starting a second would // collide on the transport and the temp-track/arm snapshot). -static void RunCaptureRealtimeMaster() +static void RunCaptureRealtimeTrack() { if (g_rtCapture) { @@ -569,29 +575,32 @@ static void RunCaptureRealtimeMaster() return; } - double start = 0.0, end = 0.0; + // Resolve the selected tracks + range exactly as the offline Track scope does. + // No track selected -> refuse (same no-op as offline track scope). + ResolvedSource src; std::string why; - if (!resolveRange(start, end, why)) + if (!ResolveScopeSource(reasampler::CaptureScope::Track, src, why)) { ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str()); return; } reasampler::CaptureRequest req; - req.sourceMode = reasampler::SourceMode::MasterMix; // realtime master scope - req.startSeconds = start; // exact bounds — no rounding - req.endSeconds = end; - req.wetDry = 1.0; // fully wet (post-fader tap) + req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope + req.startSeconds = src.startSeconds; // exact bounds — no rounding + req.endSeconds = src.endSeconds; + req.wetDry = 1.0; // fully wet (post-fader tap) req.renderTail = false; req.tailMs = 0.0; - req.sampleRate = 0; // follow project rate + req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; req.baseName = "realtime"; - // No trackGuids — master scope is not track-provenanced. + req.trackGuids = src.trackGuids; // provenance on the Sample reasampler::CaptureResult failure; - reasampler::RealtimeCaptureHandle st = g_rtBackend.begin(req, failure); + reasampler::RealtimeCaptureHandle st = + g_rtBackend.begin(req, src.sourceTracks, failure); if (!st) { // begin() validated/failed and already restored anything it touched. @@ -685,7 +694,7 @@ static bool OnHookCommand(int command, int /*flag*/) if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } - if (command == g_cmdCaptureMasterRealtime) { RunCaptureRealtimeMaster(); return true; } + if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } // Design View action family (D4). Claims only its own ids; returns false for the // rest so this hook keeps looking (per the contract). @@ -707,7 +716,7 @@ static int OnToggleAction(int command) static gaccel_register_t g_accelToggleBankPanel{}; static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; -static gaccel_register_t g_accelCaptureMasterRealtime{}; +static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( @@ -740,9 +749,9 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE")); - g_rec->Register("-gaccel", (void*)&g_accelCaptureMasterRealtime); + g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_REALTIME")); + (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_TRACK_REALTIME")); g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM")); @@ -853,19 +862,19 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); } - // Register the M8 "capture master (realtime)" action (command_id -> gaccel -> - // hookcommand). Realtime sibling of the offline CAPTURE_MASTER scope: records - // the master output in realtime into a hidden temp track, moves it into the - // bank. Dialog-free. FOREVER-STABLE id string. - g_cmdCaptureMasterRealtime = rec->Register( + // Register the "capture selected track (realtime)" action (command_id -> gaccel -> + // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the + // selected track's own output in realtime into a hidden temp track, moves it into + // the bank. Dialog-free. NEW FOREVER-STABLE id string. + g_cmdCaptureTrackRealtime = rec->Register( "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_REALTIME")); - if (g_cmdCaptureMasterRealtime) + (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_TRACK_REALTIME")); + if (g_cmdCaptureTrackRealtime) { - g_accelCaptureMasterRealtime.accel.cmd = g_cmdCaptureMasterRealtime; - g_accelCaptureMasterRealtime.desc = - "ReaSampler: capture master (realtime)"; - rec->Register("gaccel", (void*)&g_accelCaptureMasterRealtime); + g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; + g_accelCaptureTrackRealtime.desc = + "ReaSampler: capture selected track (realtime)"; + rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); } // Cancel-in-flight sibling: aborts a running realtime capture (stop + restore). diff --git a/src/render_settings.cpp b/src/render_settings.cpp index 148fd2c..7fc4072 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -55,9 +55,8 @@ SourceMode sourceModeForScope(CaptureScope scope) { switch (scope) { case CaptureScope::Item: return SourceMode::SelectedItems; case CaptureScope::Track: return SourceMode::SelectedTracks; - case CaptureScope::Master: return SourceMode::MasterMix; } - return SourceMode::MasterMix; // unreachable for a valid enum; fail to master + return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed } RangeSource inferRangeSource(bool hasRazorArea) { @@ -84,12 +83,6 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) { p.bypassAncestorFx = true; p.bypassMaster = true; return p; - case CaptureScope::Master: - // Master = whole chain. Bypass nothing. - p.bypassSelfFx = false; - p.bypassAncestorFx = false; - p.bypassMaster = false; - return p; } return p; // unreachable; bypass nothing (fail to full-chain, never over-bypass) } @@ -137,12 +130,13 @@ RazorRange razorUnionBounds(const std::vector& ranges) { } const std::vector& captureActionTable() { - // Built once (function-local static): three SCOPE actions. Tail OFF for all + // Built once (function-local static): two SCOPE actions. Tail OFF for all // (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string. Each // action infers its range (razor-else-time) at fire time and enforces its // FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET / // CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in - // main.cpp); CAPTURE_MASTER keeps its shipped id string. + // main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise + // mirror-unregistered) — to capture the master you render a track. static const std::vector table = { // Item scope — item/take FX only. NEW forever-stable id. {"CEREBELLUM_REASAMPLER_CAPTURE_ITEM", @@ -153,11 +147,6 @@ const std::vector& captureActionTable() { {"CEREBELLUM_REASAMPLER_CAPTURE_TRACK", "ReaSampler: capture selected track(s)", "track", CaptureScope::Track}, - - // Master scope — whole chain. Id string unchanged from M7 (already shipped). - {"CEREBELLUM_REASAMPLER_CAPTURE_MASTER", - "ReaSampler: capture master", "master", - CaptureScope::Master}, }; return table; } diff --git a/src/render_settings.h b/src/render_settings.h index 1e555b6..74ab139 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -66,18 +66,20 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); // --- Capture scope: the FX-scope invariant (Daniel, critical) ---------------- // -// Three FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope. +// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope. // Item -> item/take FX ONLY (no track, no parent/folder, no master FX). // Track -> item FX + the selected track's OWN track FX (no parent/folder/master). -// Master -> the whole chain (nothing bypassed). +// There is NO master scope: to capture the master you render a track instead. The +// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope +// chain for both item and track captures (bypassMaster below) — master is a +// bypass target, not a capture scope. enum class CaptureScope { Item, Track, - Master, }; // The render source mode each scope drives. Item captures selected items, Track -// captures selected tracks (via master), Master captures the master mix. +// captures selected tracks (via master). SourceMode sourceModeForScope(CaptureScope scope); // --- Range inference: razor-else-time (orthogonal to scope) ------------------- @@ -142,7 +144,7 @@ RazorRange razorUnionBounds(const std::vector& ranges); // --- Capture-action taxonomy (the bindable set main.cpp registers) ----------- // -// One row per bindable SCOPE action. Three scopes (item / track / master); the +// One row per bindable SCOPE action. Two scopes (item / track); the // range each captures (razor-else-time) is inferred at fire time, not a mode. // Tail is OFF for every row (exact bounds); a tail-on variant is a later opt-in, // YAGNI now. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos). @@ -153,17 +155,16 @@ struct CaptureActionDef { const char* commandString; // CEREBELLUM_REASAMPLER_… FOREVER-STABLE id string const char* description; // Actions-list label const char* baseName; // file-stem base for this capture - CaptureScope scope; // FX scope (item / track / master) + CaptureScope scope; // FX scope (item / track) }; // The capture-action table. Iterated by main.cpp to register the family and route // each fired command back to its definition. Kept here (pure) so the taxonomy is // one testable list, not scattered registration code. // -// Three scope rows: CAPTURE_ITEM, CAPTURE_TRACK, CAPTURE_MASTER. This replaces the -// M7 four-mode table (master / tracks / items / razor) — razor is now an inferred -// range, not a mode, and each scope enforces its FX-scope invariant via -// fxBypassPlanFor. +// Two scope rows: CAPTURE_ITEM, CAPTURE_TRACK. There is no master capture — to +// capture the master you render a track. Razor is an inferred range, not a mode, +// and each scope enforces its FX-scope invariant via fxBypassPlanFor. const std::vector& captureActionTable(); } // namespace reasampler diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index f5f7865..2469e43 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -1,5 +1,5 @@ // Standalone tests for reasampler::render_settings — no REAPER, no framework. -// Covers the pure pieces behind the three-scope capture family: the source-mode -> +// Covers the pure pieces behind the two-scope capture family: the source-mode -> // RENDER_SETTINGS bit mapping, P_RAZOREDITS parsing -> ranges + union, scope -> // source mode, range inference (razor-else-time), the FX-bypass plan (corrects // the "items captured through parent FX" defect), and the capture-action taxonomy @@ -111,15 +111,14 @@ static void testRazorUnionBounds() { // --- sourceModeForScope: scope -> render source mode ------------------------- static void testScopeSourceModes() { - // Each scope drives a distinct render source. Item -> items, Track -> tracks, - // Master -> master mix. These feed renderSettingsFor and must be supported. + // Each scope drives a distinct render source. Item -> items, Track -> tracks. + // (There is no master scope — to capture the master you render a track.) These + // feed renderSettingsFor and must be supported. CHECK(sourceModeForScope(CaptureScope::Item) == SourceMode::SelectedItems); CHECK(sourceModeForScope(CaptureScope::Track) == SourceMode::SelectedTracks); - CHECK(sourceModeForScope(CaptureScope::Master) == SourceMode::MasterMix); // Every scope's source mode is an offline-supported render source. CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item), 1.0).supported); CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track), 1.0).supported); - CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Master), 1.0).supported); } // --- inferRangeSource: razor-else-time (orthogonal to scope) ----------------- @@ -144,29 +143,22 @@ static void testItemScopeBypassesEverythingButTake() { static void testTrackScopeKeepsSelfBypassesAncestorsAndMaster() { // Track = item FX + the track's OWN FX. Keep self FX; bypass ancestors + master. + // Master stays a bypass target even though it is no longer a capture scope. FxBypassPlan p = fxBypassPlanFor(CaptureScope::Track); CHECK(!p.bypassSelfFx); // the whole point: the track's own FX stays live CHECK(p.bypassAncestorFx); // no parent/folder FX CHECK(p.bypassMaster); // no master FX } -static void testMasterScopeBypassesNothing() { - // Master = whole chain. Nothing bypassed — the full signal path renders. - FxBypassPlan p = fxBypassPlanFor(CaptureScope::Master); - CHECK(!p.bypassSelfFx); - CHECK(!p.bypassAncestorFx); - CHECK(!p.bypassMaster); -} +// --- captureActionTable: the two-scope taxonomy ------------------------------ -// --- captureActionTable: the three-scope taxonomy ---------------------------- - -static void testTableHasThreeScopeRows() { +static void testTableHasTwoScopeRows() { const auto& table = captureActionTable(); - // Exactly 3 scope rows: item, track, master. - CHECK(table.size() == 3); + // Exactly 2 scope rows: item, track. There is no master scope. + CHECK(table.size() == 2); std::set ids; - bool sawItem = false, sawTrack = false, sawMaster = false; + bool sawItem = false, sawTrack = false; for (const auto& def : table) { // Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE // (duplicate ids would collide on registration). @@ -176,26 +168,11 @@ static void testTableHasThreeScopeRows() { // Every scope resolves to a supported offline source. CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported); - if (def.scope == CaptureScope::Item) sawItem = true; - if (def.scope == CaptureScope::Track) sawTrack = true; - if (def.scope == CaptureScope::Master) sawMaster = true; + if (def.scope == CaptureScope::Item) sawItem = true; + if (def.scope == CaptureScope::Track) sawTrack = true; } CHECK(sawItem); CHECK(sawTrack); - CHECK(sawMaster); -} - -static void testMasterCommandIdIsPreserved() { - // The master scope keeps its shipped M7 id string (user keybindings depend on - // it). Item/track mint NEW ids; master's must be exactly the old value. - bool foundMaster = false; - for (const auto& def : captureActionTable()) - if (def.scope == CaptureScope::Master) { - CHECK(std::string(def.commandString) == - "CEREBELLUM_REASAMPLER_CAPTURE_MASTER"); - foundMaster = true; - } - CHECK(foundMaster); } int main() { @@ -213,9 +190,7 @@ int main() { testRangeInference(); testItemScopeBypassesEverythingButTake(); testTrackScopeKeepsSelfBypassesAncestorsAndMaster(); - testMasterScopeBypassesNothing(); - testTableHasThreeScopeRows(); - testMasterCommandIdIsPreserved(); + testTableHasTwoScopeRows(); if (g_fail == 0) std::printf("render_settings: all tests passed\n"); else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);