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
-1
View File
@@ -149,7 +149,6 @@ add_library(reaper_reasampler MODULE
src/view.cpp
src/track_guid.cpp
src/actions.cpp
src/render_settings.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model insert_plan render_settings)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
+6 -3
View File
@@ -1,11 +1,14 @@
// capture.cpp — REAPER-facing offline-render backend (M3 spike).
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
//
// 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).
//
// Scope (M7): the full offline source family — master mix / time selection,
// selected tracks, selected items, razor area — all wet-only with optional tail.
// Renders a CaptureRequest's source over its requested range. The full three-scope
// capture family (item / track / master, each over a razor-else-time range) is
// driven here — all wet-only with optional tail. FX scope is enforced by the
// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture;
// this backend is source-agnostic and does not itself read the DAW selection.
// Drives the RENDER_* project settings via GetSetProjectInfo / _String
// (the source-selection bits come from render_settings.cpp, the pure mapping),
// snapshots and restores every setting it changes (non-destructive), triggers a
+7 -7
View File
@@ -41,7 +41,7 @@ struct CaptureRequest {
double startSeconds = 0.0;
double endSeconds = 0.0;
// 1.0 = fully wet, 0.0 = fully dry. All M7 actions set this to 1.0 (wet).
// 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet).
// The field is kept as the seam for future true-dry work (M10 null test):
// true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires
// FX-bypass-around-render or the M8 realtime pre-FX path, and will be
@@ -101,12 +101,12 @@ public:
virtual CaptureResult capture(const CaptureRequest& request) = 0;
};
// Deterministic offline-render backend. M7 implements the full offline source
// family — master mix / time selection, selected tracks, selected items, razor
// area — all wet-only (render_settings.h) with optional tail. The source
// selection + range are resolved by the caller (the action layer) and handed in
// via the CaptureRequest; the backend drives RENDER_* and never reads the DAW
// selection itself. SourceMode::Realtime returns UnsupportedMode (that is M8).
// Deterministic offline-render backend. Drives the full offline source family —
// master mix / time selection, selected tracks, selected items, razor area — all
// wet-only (render_settings.h) with optional tail. The source selection + range
// are resolved by the caller (the action layer) and handed in via the
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
class OfflineRenderBackend : public ICaptureBackend {
public:
CaptureResult capture(const CaptureRequest& request) override;
+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.
+67 -25
View File
@@ -1,4 +1,4 @@
// render_settings.cpp — pure logic for the M7 capture action family. See header.
// render_settings.cpp — pure logic for the three-scope capture action family. See header.
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
#include "render_settings.h"
@@ -8,8 +8,9 @@
namespace reasampler {
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// All M7 actions are wet-only. `wetDry` is accepted so CaptureRequest.wetDry
// remains the seam for future M10 dry work, but it does not affect the mapping.
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
// handled by fxBypassPlanFor, not by these render bits.
RenderSettingsChoice c;
switch (mode) {
@@ -50,6 +51,49 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
return c;
}
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
}
RangeSource inferRangeSource(bool hasRazorArea) {
// Razor wins when present; otherwise the time selection. Orthogonal to scope.
return hasRazorArea ? RangeSource::Razor : RangeSource::TimeSelection;
}
FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
FxBypassPlan p;
switch (scope) {
case CaptureScope::Item:
// Item = take/item FX ONLY. Bypass the item's own track FX, every
// ancestor's FX, and the master's FX. (Take FX live in the item and
// are always rendered — there is no track to bypass them from.)
p.bypassSelfFx = true;
p.bypassAncestorFx = true;
p.bypassMaster = true;
return p;
case CaptureScope::Track:
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
// every ancestor (parent/folder) and the master. Parent/master GAIN
// still applies (I_FXEN is FX-only) — documented boundary.
p.bypassSelfFx = false;
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)
}
std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges;
std::istringstream in(razorString);
@@ -59,7 +103,7 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
std::string startTok, endTok, guidTok;
while (in >> startTok >> endTok >> guidTok) {
// Envelope-lane areas carry a real GUID; skip them (M7 = track audio).
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
// A track-audio area's GUID token is the empty quoted string `""`.
if (guidTok != "\"\"") continue;
@@ -93,29 +137,27 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
}
const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): four wet-only actions. Tail OFF for all
// (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string.
// Dry variants deferred to M10; see kRenderPreFaderStems note in the header.
// Built once (function-local static): three 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.
static const std::vector<CaptureActionDef> table = {
// Master mix — wet only (master IS the mix; no pre-FX concept applies).
// Item scope — item/take FX only. NEW forever-stable id.
{"CEREBELLUM_REASAMPLER_CAPTURE_ITEM",
"ReaSampler: capture selected item(s)", "item",
CaptureScope::Item},
// Track scope — item FX + the track's own FX. NEW forever-stable id.
{"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 mix", "master_mix",
SourceMode::MasterMix, 1.0},
// Selected tracks — wet (via master, &128).
{"CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET",
"ReaSampler: capture selected tracks", "tracks_wet",
SourceMode::SelectedTracks, 1.0},
// Selected items — wet, single file (&32 | single-file).
{"CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET",
"ReaSampler: capture selected items", "items_wet",
SourceMode::SelectedItems, 1.0},
// Razor area — wet, single file (&4096 | single-file).
{"CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET",
"ReaSampler: capture razor area", "razor_wet",
SourceMode::RazorArea, 1.0},
"ReaSampler: capture master", "master",
CaptureScope::Master},
};
return table;
}
+95 -36
View File
@@ -1,16 +1,22 @@
#pragma once
// render_settings — the REAPER-free logic behind the M7 capture action family.
// render_settings — the REAPER-free logic behind the capture action family.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The capture shell (capture.cpp) reads
// the actual DAW state (time selection, selected tracks/items, razor strings) and
// hands the raw values here so the three genuinely-pure, easy-to-get-wrong pieces
// are unit-tested outside the DAW:
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
// action layer (main.cpp) read the actual DAW state (time selection, selected
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside
// the DAW:
//
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only; M7 scope).
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only).
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
// 3. the capture-action table (id string, description, source mode, wet/dry) —
// the taxonomy, in one place so main.cpp iterates it instead of hand-listing.
// 3. range inference: razor-present -> razor union, else time selection. Range
// is a SOURCE choice orthogonal to the capture scope.
// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which
// tracks' FX to bypass so each scope hears only the FX it should (the M7
// "items captured through parent FX" defect is corrected here).
// 5. the capture-action table (id string, description, scope) — the taxonomy,
// in one place so main.cpp iterates it instead of hand-listing.
//
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
@@ -25,30 +31,31 @@ namespace reasampler {
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
//
// Only the bits M7 actually uses are named. Values are the documented bit
// Only the bits this module actually uses are named. Values are the documented bit
// weights; the DOC of each is the SDK header's, not a guess.
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// NOTE: kRenderPreFaderStems (&8192) is NOT used in M7. REAPER offline render has
// no true pre-FX "dry" bit. Pre-fader stems are post-FX/pre-fader-volume — an
// approximation, not a dry capture. True pre-FX dry requires FX-bypass-around-
// render or the M8 realtime pre-FX path; it will be designed with the M10 null
// test. All M7 capture actions are wet-only.
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that
// fall outside a scope — NOT by any render bit. All capture actions render wet
// (post the FX that remain enabled); the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
// for SourceMode::Realtime (that is the M8 backend, not offline render).
struct RenderSettingsChoice {
int settings = kRenderMasterMix;
bool supported = true; // false => not an offline-render source in M7
bool supported = true; // false => not an offline-render source (e.g. Realtime)
};
// Maps a source mode to the wet RENDER_SETTINGS value for M7.
// All M7 actions are wet-only (post-FX). `wetDry` is accepted but ignored for
// the mapping — retained in CaptureRequest as the seam for future M10 dry work.
// Maps a source mode to its RENDER_SETTINGS value (which content the render
// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits.
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest
// as the seam for future dry work (M10 null test).
//
// CONFIRMED (SDK header ~3041):
// MasterMix / TimeSelection -> master mix (0).
@@ -57,8 +64,60 @@ struct RenderSettingsChoice {
// RazorArea -> &4096| single-file.
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.
// 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).
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.
SourceMode sourceModeForScope(CaptureScope scope);
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
//
// Every scope action infers its render range the same way: if a razor area is
// present, use the razor union; otherwise use the time selection. Razor is a
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
enum class RangeSource {
Razor, // a razor area is present -> use its union bound
TimeSelection, // no razor -> use the time selection
};
// Picks the range source. Pure so the "razor wins when present" rule is tested
// without a DAW; the shell supplies whether any razor area was found.
RangeSource inferRangeSource(bool hasRazorArea);
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
//
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX,
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and
// clears I_FXEN on each flagged track, snapshotting first (RAII restore).
//
// SCOPE BOUNDARY (documented, DAW-confirm): I_FXEN bypasses a track's FX plugins
// but NOT its volume/pan/routing. "No parent FX" is satisfied by bypassing parent
// FX only — parent/master GAIN still applies to a Track capture rendered via
// master (&128). Neutralizing parent gain would be a larger, surprising mutation
// (and is not what "FX scope" means); the least-surprising default is FX-only
// bypass. Flagged for Daniel's DAW confirmation.
struct FxBypassPlan {
bool bypassSelfFx = false; // the captured track's own FX
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
bool bypassMaster = false; // the master track's FX
};
FxBypassPlan fxBypassPlanFor(CaptureScope scope);
// A single razor-edit area: a time range on one track (envelope GUID ignored —
// M7 captures track-audio razor areas, not envelope lanes).
// razor captures target track-audio areas, not envelope lanes).
struct RazorRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
@@ -69,8 +128,8 @@ struct RazorRange {
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
// GUID like {…} for an envelope-lane area.
//
// Returns only the track-audio ranges (envelope-lane triples are skipped — M7
// renders track audio). Malformed/short trailing tokens are ignored, not fatal.
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal.
// A range with end <= start is dropped (no negative/empty areas leak through).
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
@@ -81,28 +140,28 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
//
// One row per bindable action. All M7 rows are wet-only (post-FX). Tail is OFF
// for every row (exact bounds); a tail-on variant is a later opt-in, YAGNI now.
// Yields a bounded, discoverable set with NO dialogs (the tool's no-clutter ethos).
// One row per bindable SCOPE action. Three scopes (item / track / master); 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).
//
// commandString is FOREVER-STABLE (user keybindings key off it) — never change a
// shipped value. baseName feeds the file stem (sanitized by capture_paths).
// wetDry is retained as the M10 seam; all M7 rows set it to 1.0.
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
SourceMode sourceMode;
double wetDry; // 1.0 (wet) for all M7 rows; seam for M10 dry
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)
};
// The full M7 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.
// 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.
//
// Four wet-only rows: CAPTURE_MASTER, CAPTURE_TRACKS_WET, CAPTURE_ITEMS_WET,
// CAPTURE_RAZOR_WET. Dry variants are deferred to M10 (null-test work) — see the
// kRenderPreFaderStems note above for why offline dry is non-trivial.
// 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.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler
+86 -25
View File
@@ -1,7 +1,9 @@
// Standalone tests for reasampler::render_settings — no REAPER, no framework.
// Covers the three pure pieces behind the M7 capture family: the source-mode ->
// RENDER_SETTINGS bit mapping (wet-only), P_RAZOREDITS parsing -> ranges + union,
// and the capture-action taxonomy table (stable ids, coverage of every mode).
// Covers the pure pieces behind the three-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
// table (stable ids, one row per scope).
#include "../src/render_settings.h"
@@ -24,7 +26,7 @@ static void testMasterMixWet() {
CHECK(c.supported);
// TimeSelection aliases master mix — same result.
CHECK(renderSettingsFor(SourceMode::TimeSelection, 1.0).settings == kRenderMasterMix);
// wetDry argument is irrelevant for M7 (all actions are wet); passing 0.0
// wetDry argument is irrelevant (all three-scope capture actions are wet); passing 0.0
// must still yield the same wet master-mix bits.
CHECK(renderSettingsFor(SourceMode::MasterMix, 0.0).settings == kRenderMasterMix);
}
@@ -77,7 +79,7 @@ static void testParseMultipleAreas() {
static void testParseSkipsEnvelopeLaneAreas() {
// A triple whose GUID is a real {…} is an ENVELOPE-lane area — skipped, since
// M7 renders track audio. Only the track-audio triple survives.
// razor captures render track audio only. Only the track-audio triple survives.
auto r = parseRazorEdits(
"1.0 2.0 \"\" 3.0 4.0 {AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}");
CHECK(r.size() == 1);
@@ -106,40 +108,94 @@ static void testRazorUnionBounds() {
CHECK(empty.startSeconds == 0.0 && empty.endSeconds == 0.0);
}
// --- captureActionTable: the taxonomy ----------------------------------------
// --- sourceModeForScope: scope -> render source mode -------------------------
static void testTableHasFourWetOnlyRows() {
static void testScopeSourceModes() {
// Each scope drives a distinct render source. Item -> items, Track -> tracks,
// Master -> master mix. 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) -----------------
static void testRangeInference() {
// Razor present -> razor union wins; no razor -> time selection.
CHECK(inferRangeSource(true) == RangeSource::Razor);
CHECK(inferRangeSource(false) == RangeSource::TimeSelection);
}
// --- fxBypassPlanFor: the FX-scope invariant ----------------------------------
static void testItemScopeBypassesEverythingButTake() {
// Item = take/item FX ONLY. Bypass the item's own track FX, its ancestors, and
// the master. (If this returned bypassSelfFx=false the M7 defect — items heard
// through the track's FX — would recur; the assertion pins the fix.)
FxBypassPlan p = fxBypassPlanFor(CaptureScope::Item);
CHECK(p.bypassSelfFx);
CHECK(p.bypassAncestorFx);
CHECK(p.bypassMaster);
}
static void testTrackScopeKeepsSelfBypassesAncestorsAndMaster() {
// Track = item FX + the track's OWN FX. Keep self FX; bypass ancestors + master.
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 three-scope taxonomy ----------------------------
static void testTableHasThreeScopeRows() {
const auto& table = captureActionTable();
// Exactly 4 wet-only rows: MASTER, TRACKS_WET, ITEMS_WET, RAZOR_WET.
CHECK(table.size() == 4);
// Exactly 3 scope rows: item, track, master.
CHECK(table.size() == 3);
std::set<std::string> ids;
bool sawMaster = false, sawTracks = false, sawItems = false, sawRazor = false;
bool sawItem = false, sawTrack = false, sawMaster = false;
for (const auto& def : table) {
// Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE
// (duplicate ids would collide on registration).
std::string id = def.commandString;
CHECK(id.rfind("CEREBELLUM_REASAMPLER_", 0) == 0);
CHECK(ids.insert(id).second); // false if duplicate
// Every row is wet (>=0.5) and a real offline source.
CHECK(def.wetDry >= 0.5);
CHECK(renderSettingsFor(def.sourceMode, def.wetDry).supported);
// Every scope resolves to a supported offline source.
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
if (def.sourceMode == SourceMode::MasterMix) sawMaster = true;
if (def.sourceMode == SourceMode::SelectedTracks) sawTracks = true;
if (def.sourceMode == SourceMode::SelectedItems) sawItems = true;
if (def.sourceMode == SourceMode::RazorArea) sawRazor = true;
if (def.scope == CaptureScope::Item) sawItem = true;
if (def.scope == CaptureScope::Track) sawTrack = true;
if (def.scope == CaptureScope::Master) sawMaster = true;
}
CHECK(sawItem);
CHECK(sawTrack);
CHECK(sawMaster);
CHECK(sawTracks);
CHECK(sawItems);
CHECK(sawRazor);
}
static void testNoDryRowsInTable() {
// M7 ships wet-only. No table row must have wetDry < 0.5.
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())
CHECK(def.wetDry >= 0.5);
if (def.scope == CaptureScope::Master) {
CHECK(std::string(def.commandString) ==
"CEREBELLUM_REASAMPLER_CAPTURE_MASTER");
foundMaster = true;
}
CHECK(foundMaster);
}
int main() {
@@ -153,8 +209,13 @@ int main() {
testParseSkipsEnvelopeLaneAreas();
testParseEmptyAndMalformed();
testRazorUnionBounds();
testTableHasFourWetOnlyRows();
testNoDryRowsInTable();
testScopeSourceModes();
testRangeInference();
testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
testMasterScopeBypassesNothing();
testTableHasThreeScopeRows();
testMasterCommandIdIsPreserved();
if (g_fail == 0) std::printf("render_settings: all tests passed\n");
else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);