Rework capture into three FX-scope actions with inferred range

Replace the four capture modes with item/track/master scope actions. Each infers
its range (razor-else-time) and enforces FX scope via non-destructive
FX-bypass-around-render (RAII I_FXEN snapshot/restore over ancestors + master).
Corrects the defect of items captured through parent FX.
This commit is contained in:
2026-07-23 13:12:36 -04:00
parent c78c7e3ddc
commit df03b5e759
7 changed files with 470 additions and 215 deletions
+209 -118
View File
@@ -43,12 +43,17 @@
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
// ---- M7 capture action family ----------------------------------------------
// Four wet-only bindable actions from captureActionTable() (render_settings, pure):
// master mix, selected tracks, selected items, razor area — all wet (post-FX).
// Tail is OFF for every row (exact bounds); a tail-on variant is a later opt-in
// (YAGNI). The M3 "capture master mix (spike)" action is RETIRED and replaced by
// this family. Dry variants are deferred to M10 (null-test work).
// ---- 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):
// 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.
//
// 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-
@@ -56,6 +61,16 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
static std::vector<int> g_captureCmdIds;
static std::vector<gaccel_register_t> 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.
static const char* const kRetiredCaptureCmdStrings[] = {
"CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET",
};
// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string.
// The docked grid window is display-only this wave (Wave A) — the action just
// shows/hides it; it never captures, inserts, or mutates the bank.
@@ -100,24 +115,26 @@ static void OnTimer()
reasampler::bankPanelRefresh();
}
// --- M7 source resolvers ----------------------------------------------------
// Each resolves a source mode to (1) the exact render range in project seconds and
// (2) the track GUIDs, when track-scoped. They ONLY READ DAW state (selection, time
// selection, razor strings) — they never mutate it (non-destructive). Returning
// false means "nothing to capture" (empty selection / no razor / empty range); the
// caller reports it and writes nothing.
// --- Scope-action source resolution -----------------------------------------
// The three scope actions (item / track / master) each resolve to (1) an exact
// render range in project seconds — razor-else-time, inferred here — and (2) the
// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads
// are non-destructive: selection, razor, and time selection are read, never
// mutated. Returning false means "nothing to capture" (empty selection / no
// range); the caller reports it and writes nothing.
// The resolved source: exact bounds + optional track GUIDs.
// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample
// provenance GUIDs). `sourceTracks` is empty for Master scope.
struct ResolvedSource
{
double startSeconds = 0.0;
double endSeconds = 0.0;
std::vector<std::string> trackGuids; // populated only for SelectedTracks
double startSeconds = 0.0;
double endSeconds = 0.0;
std::vector<MediaTrack*> sourceTracks; // item's/selected tracks; empty for master
std::vector<std::string> trackGuids; // canonical GUIDs of sourceTracks
};
// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false,
// isLoop=false) reads the current time selection. Used by master mix (the range is
// the time selection) and as the time window for selected-track captures.
// isLoop=false) reads the current time selection.
static bool resolveTimeSelection(double& start, double& end)
{
start = 0.0; end = 0.0;
@@ -125,62 +142,11 @@ static bool resolveTimeSelection(double& start, double& end)
return end > start;
}
// Master mix / time selection: bounds = the time selection; no track GUIDs.
static bool resolveMaster(ResolvedSource& out)
{
return resolveTimeSelection(out.startSeconds, out.endSeconds);
}
// Selected tracks: the render time window is the time selection (RENDER_SETTINGS
// selects WHICH tracks; the custom bounds select the WHEN). We also collect the
// selected tracks' GUIDs for the Sample's provenance. Requires both a non-empty
// track selection AND a time selection (the bounds come from the latter).
static bool resolveSelectedTracks(ResolvedSource& out)
{
const int n = CountSelectedTracks(nullptr); // nullptr = active project
if (n <= 0) return false;
for (int i = 0; i < n; ++i)
{
MediaTrack* tr = GetSelectedTrack(nullptr, i);
if (!tr) continue;
std::string g = reasampler::guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
return resolveTimeSelection(out.startSeconds, out.endSeconds);
}
// Selected items: bounds = the union [min position, max position+length] across
// the selected items (D_POSITION / D_LENGTH — SDK header ~1990/1991). Exact, no
// rounding. RENDER_SETTINGS selects the items; the bounds keep the render window
// tight around them.
static bool resolveSelectedItems(ResolvedSource& out)
{
const int n = CountSelectedMediaItems(nullptr);
if (n <= 0) return false;
bool any = false;
double lo = 0.0, hi = 0.0;
for (int i = 0; i < n; ++i)
{
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (!it) continue;
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
const double end = pos + len;
if (!any) { lo = pos; hi = end; any = true; }
else { if (pos < lo) lo = pos; if (end > hi) hi = end; }
}
if (!any) return false;
out.startSeconds = lo;
out.endSeconds = hi;
return out.endSeconds > out.startSeconds;
}
// Razor area: razor edits live PER TRACK (P_RAZOREDITS — SDK header ~2899:
// space-separated triples of start, end, envGuidString). We read every track's
// razor string, parse the track-audio areas (pure parseRazorEdits), and take the
// union bound as the render window. RENDER_SETTINGS&4096 selects the razor content;
// the bounds keep the window tight. Reads only — never clears the razor selection.
static bool resolveRazorArea(ResolvedSource& out)
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits),
// and returns the union bound. Reads only — never clears the razor selection.
// Returns false when no track-audio razor area exists on any track.
static bool resolveRazorRange(double& start, double& end)
{
std::vector<reasampler::RazorRange> allRanges;
const int n = CountTracks(nullptr);
@@ -188,8 +154,6 @@ static bool resolveRazorArea(ResolvedSource& out)
{
MediaTrack* tr = GetTrack(nullptr, i);
if (!tr) continue;
// GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf, false) reads the
// razor string into buf. Big buffer: many areas can accumulate.
std::vector<char> buf(8192, '\0');
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
continue;
@@ -199,70 +163,193 @@ static bool resolveRazorArea(ResolvedSource& out)
}
if (allRanges.empty()) return false;
reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges);
out.startSeconds = u.startSeconds;
out.endSeconds = u.endSeconds;
return out.endSeconds > out.startSeconds;
start = u.startSeconds;
end = u.endSeconds;
return end > start;
}
// Dispatches to the right resolver for a source mode. Returns false with a reason
// in `why` when there is nothing to capture (so the action can log precisely).
static bool ResolveSource(reasampler::SourceMode mode, ResolvedSource& out,
std::string& why)
// Infers the render RANGE for any scope: razor union when a razor area is present,
// else the time selection (pure inferRangeSource decides which). Orthogonal to
// scope. Returns false (with a reason) when neither yields a non-empty range.
static bool resolveRange(double& start, double& end, std::string& why)
{
using reasampler::SourceMode;
switch (mode)
double rzStart = 0.0, rzEnd = 0.0;
const bool hasRazor = resolveRazorRange(rzStart, rzEnd);
if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor)
{
case SourceMode::MasterMix:
case SourceMode::TimeSelection:
if (resolveMaster(out)) return true;
why = "no time selection (make a time selection first)";
return false;
case SourceMode::SelectedTracks:
if (resolveSelectedTracks(out)) return true;
why = "select at least one track AND make a time selection";
return false;
case SourceMode::SelectedItems:
if (resolveSelectedItems(out)) return true;
why = "select at least one media item";
return false;
case SourceMode::RazorArea:
if (resolveRazorArea(out)) return true;
why = "no razor edit area found on any track";
return false;
case SourceMode::Realtime:
why = "realtime capture is the M8 backend, not offline render";
return false;
start = rzStart; end = rzEnd;
return true; // resolveRazorRange already verified end > start
}
why = "unknown source mode";
if (resolveTimeSelection(start, end)) return true;
why = "make a razor area or a time selection first";
return false;
}
// Runs one capture-action-table row: resolve its source, build a CaptureRequest,
// hand it to the offline backend, add the Sample to the bank, persist + mark dirty.
// The load-bearing principle holds structurally — this path writes a file + a bank
// index entry ONLY; it never calls InsertMedia or touches the arrange/timeline.
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
static bool collectSelectedTracks(ResolvedSource& out)
{
const int n = CountSelectedTracks(nullptr); // nullptr = active project
if (n <= 0) return false;
for (int i = 0; i < n; ++i)
{
MediaTrack* tr = GetSelectedTrack(nullptr, i);
if (!tr) continue;
out.sourceTracks.push_back(tr);
std::string g = reasampler::guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
return !out.sourceTracks.empty();
}
// Collects the tracks that own the selected items (Item scope) into
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning
// track (SDK header, verify). GUIDs recorded for provenance.
static bool collectSelectedItemTracks(ResolvedSource& out)
{
const int n = CountSelectedMediaItems(nullptr);
if (n <= 0) return false;
for (int i = 0; i < n; ++i)
{
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (!it) continue;
MediaTrack* tr = GetMediaItem_Track(it);
if (!tr) continue;
// Dedup: several selected items can share a track.
bool seen = false;
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
if (seen) continue;
out.sourceTracks.push_back(tr);
std::string g = reasampler::guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
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.
static bool ResolveScopeSource(reasampler::CaptureScope scope,
ResolvedSource& out, std::string& why)
{
using reasampler::CaptureScope;
switch (scope)
{
case CaptureScope::Item:
if (!collectSelectedItemTracks(out)) {
why = "select at least one media item"; return false;
}
break;
case CaptureScope::Track:
if (!collectSelectedTracks(out)) {
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);
}
// --- FX-bypass-around-render (RAII, non-destructive) ------------------------
// Snapshots and clears I_FXEN on the tracks a scope must NOT hear the FX of, then
// restores every snapshotted value on EVERY exit path (including the render's).
// I_FXEN bypasses a track's FX plugins only — NOT its volume/pan/routing (so a
// Track capture rendered via master still carries parent/master GAIN; documented
// boundary, DAW-confirm). Structurally non-destructive: no takes, no items, no
// project restructuring — only a transient FX-enable toggle, always restored.
class FxBypassGuard
{
public:
// scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose
// ancestor chains (walked via GetParentTrack) + the master are bypassed per the
// plan. proj is the active project (for GetMasterTrack).
FxBypassGuard(reasampler::CaptureScope scope,
const std::vector<MediaTrack*>& sourceTracks,
ReaProject* proj)
{
const reasampler::FxBypassPlan plan = reasampler::fxBypassPlanFor(scope);
for (MediaTrack* tr : sourceTracks)
{
if (!tr) continue;
if (plan.bypassSelfFx) bypass(tr);
if (plan.bypassAncestorFx)
{
// Walk parents to the top: GetParentTrack returns the immediate
// parent (folder) track, nullptr at the outermost level (SDK
// header ~2407). The master is NOT returned here — handled below.
for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p))
bypass(p);
}
}
if (plan.bypassMaster)
{
// GetMasterTrack(proj) -> the master track (SDK header ~1925); I_FXEN
// on it bypasses the master FX chain, leaving master gain/routing live.
if (MediaTrack* master = GetMasterTrack(proj)) bypass(master);
}
}
~FxBypassGuard()
{
// Restore in reverse for symmetry (order is not load-bearing — each track
// appears once, snapshots are independent).
for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it)
SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen);
}
FxBypassGuard(const FxBypassGuard&) = delete;
FxBypassGuard& operator=(const FxBypassGuard&) = delete;
private:
struct Snap { MediaTrack* track; double fxen; };
std::vector<Snap> snapshots_;
// Snapshot I_FXEN once per track (dedup: an ancestor shared by two selected
// tracks must be restored to its ORIGINAL value, not a re-snapshot of the
// already-bypassed 0), then clear it.
void bypass(MediaTrack* tr)
{
for (const Snap& s : snapshots_) if (s.track == tr) return; // already done
const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN");
snapshots_.push_back({tr, fxen});
SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194)
}
};
// Runs one capture-action-table row: resolve its scope source + range, snapshot &
// clear the out-of-scope FX (RAII), render via the offline backend, add the Sample
// to the bank, persist + mark dirty. The load-bearing principle holds structurally
// — this path writes a file + a bank index entry ONLY; it never calls InsertMedia
// or touches the arrange/timeline. Non-destructive: FX-enable is fully restored on
// every path by FxBypassGuard, and the backend restores every RENDER_* setting.
static void RunCapture(const reasampler::CaptureActionDef& def)
{
ResolvedSource src;
std::string why;
if (!ResolveSource(def.sourceMode, src, why))
if (!ResolveScopeSource(def.scope, src, why))
{
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
return;
}
reasampler::CaptureRequest req;
req.sourceMode = def.sourceMode;
req.sourceMode = reasampler::sourceModeForScope(def.scope);
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds;
req.wetDry = def.wetDry; // 1.0 wet (all M7 actions are wet-only)
req.renderTail = false; // exact bounds, no tail (M7 default)
req.wetDry = 1.0; // wet post the FX left enabled by the scope
req.renderTail = false; // exact bounds, no tail (default)
req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
req.baseName = def.baseName;
req.trackGuids = src.trackGuids; // recorded on the Sample (track captures)
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
// Bypass the out-of-scope FX for the duration of the render. Restored on EVERY
// exit path below (RAII), including backend failures. proj = active project.
ReaProject* proj = EnumProjects(-1, nullptr, 0);
FxBypassGuard fxGuard(def.scope, src.sourceTracks, proj);
reasampler::OfflineRenderBackend backend;
reasampler::CaptureResult res = backend.capture(req);
@@ -332,7 +419,7 @@ static void RunInsertSelected(bool conform)
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
// M7 capture family: command ids parallel captureActionTable() 1:1 by index.
// Three-scope capture family: command ids parallel captureActionTable() 1:1 by index.
// Claim the fired id if it is one of ours and route to its table row.
for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i)
if (command == g_captureCmdIds[i])
@@ -388,7 +475,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
// Mirror-unregister the M7 capture family: gaccel + command_id per row,
// Mirror-unregister the capture family: gaccel + command_id per row,
// with '-'-prefixed strings (per the contract). The FOREVER-STABLE id
// strings come from the same table used to register them.
{
@@ -400,6 +487,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_rec->Register("-command_id", (void*)table[i].commandString);
}
}
// Retire the removed M7 command ids (command_id only — we never held a
// gaccel for them this session). Clears stale user keybindings on unload.
for (const char* id : kRetiredCaptureCmdStrings)
g_rec->Register("-command_id", (void*)id);
}
// Destroy the docked window and release cached thumbnails before we drop
// the API pointers (DockWindowRemove/DestroyWindow need them live).
@@ -420,7 +511,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_hInst = hInstance;
g_rec = rec;
// Register the M7 capture action family (command_id -> gaccel per table row).
// Register the three-scope capture action family (command_id -> gaccel per table row).
// The single hookcommand below routes every fired id back to its row by index.
// g_captureAccels must be sized BEFORE the loop and never reallocated after —
// REAPER holds a pointer to each element until we mirror-unregister it.