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:
@@ -149,7 +149,6 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/view.cpp
|
src/view.cpp
|
||||||
src/track_guid.cpp
|
src/track_guid.cpp
|
||||||
src/actions.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_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})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
+6
-3
@@ -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
|
// Compiled into the reaper_reasampler MODULE. Includes
|
||||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU
|
// 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).
|
// that defines the API pointers; here they are extern (CLAUDE.md §contract).
|
||||||
//
|
//
|
||||||
// Scope (M7): the full offline source family — master mix / time selection,
|
// Renders a CaptureRequest's source over its requested range. The full three-scope
|
||||||
// selected tracks, selected items, razor area — all wet-only with optional tail.
|
// 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
|
// Drives the RENDER_* project settings via GetSetProjectInfo / _String
|
||||||
// (the source-selection bits come from render_settings.cpp, the pure mapping),
|
// (the source-selection bits come from render_settings.cpp, the pure mapping),
|
||||||
// snapshots and restores every setting it changes (non-destructive), triggers a
|
// snapshots and restores every setting it changes (non-destructive), triggers a
|
||||||
|
|||||||
+7
-7
@@ -41,7 +41,7 @@ struct CaptureRequest {
|
|||||||
double startSeconds = 0.0;
|
double startSeconds = 0.0;
|
||||||
double endSeconds = 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):
|
// 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
|
// 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
|
// 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;
|
virtual CaptureResult capture(const CaptureRequest& request) = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deterministic offline-render backend. M7 implements the full offline source
|
// Deterministic offline-render backend. Drives the full offline source family —
|
||||||
// family — master mix / time selection, selected tracks, selected items, razor
|
// master mix / time selection, selected tracks, selected items, razor area — all
|
||||||
// area — all wet-only (render_settings.h) with optional tail. The source
|
// wet-only (render_settings.h) with optional tail. The source selection + range
|
||||||
// selection + range are resolved by the caller (the action layer) and handed in
|
// are resolved by the caller (the action layer) and handed in via the
|
||||||
// via the CaptureRequest; the backend drives RENDER_* and never reads the DAW
|
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
|
||||||
// selection itself. SourceMode::Realtime returns UnsupportedMode (that is M8).
|
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
|
||||||
class OfflineRenderBackend : public ICaptureBackend {
|
class OfflineRenderBackend : public ICaptureBackend {
|
||||||
public:
|
public:
|
||||||
CaptureResult capture(const CaptureRequest& request) override;
|
CaptureResult capture(const CaptureRequest& request) override;
|
||||||
|
|||||||
+209
-118
@@ -43,12 +43,17 @@
|
|||||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
|
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
|
||||||
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
|
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
|
||||||
|
|
||||||
// ---- M7 capture action family ----------------------------------------------
|
// ---- Capture action family (three FX scopes) -------------------------------
|
||||||
// Four wet-only bindable actions from captureActionTable() (render_settings, pure):
|
// Three bindable SCOPE actions from captureActionTable() (render_settings, pure):
|
||||||
// master mix, selected tracks, selected items, razor area — all wet (post-FX).
|
// capture item / track / master. Each infers its range (razor-else-time) and
|
||||||
// Tail is OFF for every row (exact bounds); a tail-on variant is a later opt-in
|
// enforces the FX-scope invariant via FX-bypass-around-render (FxBypassGuard):
|
||||||
// (YAGNI). The M3 "capture master mix (spike)" action is RETIRED and replaced by
|
// Item -> take/item FX only (bypass the item's track + ancestors + master).
|
||||||
// this family. Dry variants are deferred to M10 (null-test work).
|
// 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
|
// 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-
|
// 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<int> g_captureCmdIds;
|
||||||
static std::vector<gaccel_register_t> g_captureAccels;
|
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.
|
// 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
|
// 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.
|
// shows/hides it; it never captures, inserts, or mutates the bank.
|
||||||
@@ -100,24 +115,26 @@ static void OnTimer()
|
|||||||
reasampler::bankPanelRefresh();
|
reasampler::bankPanelRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- M7 source resolvers ----------------------------------------------------
|
// --- Scope-action source resolution -----------------------------------------
|
||||||
// Each resolves a source mode to (1) the exact render range in project seconds and
|
// The three scope actions (item / track / master) each resolve to (1) an exact
|
||||||
// (2) the track GUIDs, when track-scoped. They ONLY READ DAW state (selection, time
|
// render range in project seconds — razor-else-time, inferred here — and (2) the
|
||||||
// selection, razor strings) — they never mutate it (non-destructive). Returning
|
// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads
|
||||||
// false means "nothing to capture" (empty selection / no razor / empty range); the
|
// are non-destructive: selection, razor, and time selection are read, never
|
||||||
// caller reports it and writes nothing.
|
// 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
|
struct ResolvedSource
|
||||||
{
|
{
|
||||||
double startSeconds = 0.0;
|
double startSeconds = 0.0;
|
||||||
double endSeconds = 0.0;
|
double endSeconds = 0.0;
|
||||||
std::vector<std::string> trackGuids; // populated only for SelectedTracks
|
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,
|
// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false,
|
||||||
// isLoop=false) reads the current time selection. Used by master mix (the range is
|
// isLoop=false) reads the current time selection.
|
||||||
// the time selection) and as the time window for selected-track captures.
|
|
||||||
static bool resolveTimeSelection(double& start, double& end)
|
static bool resolveTimeSelection(double& start, double& end)
|
||||||
{
|
{
|
||||||
start = 0.0; end = 0.0;
|
start = 0.0; end = 0.0;
|
||||||
@@ -125,62 +142,11 @@ static bool resolveTimeSelection(double& start, double& end)
|
|||||||
return end > start;
|
return end > start;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Master mix / time selection: bounds = the time selection; no track GUIDs.
|
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
||||||
static bool resolveMaster(ResolvedSource& out)
|
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits),
|
||||||
{
|
// and returns the union bound. Reads only — never clears the razor selection.
|
||||||
return resolveTimeSelection(out.startSeconds, out.endSeconds);
|
// Returns false when no track-audio razor area exists on any track.
|
||||||
}
|
static bool resolveRazorRange(double& start, double& end)
|
||||||
|
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
std::vector<reasampler::RazorRange> allRanges;
|
std::vector<reasampler::RazorRange> allRanges;
|
||||||
const int n = CountTracks(nullptr);
|
const int n = CountTracks(nullptr);
|
||||||
@@ -188,8 +154,6 @@ static bool resolveRazorArea(ResolvedSource& out)
|
|||||||
{
|
{
|
||||||
MediaTrack* tr = GetTrack(nullptr, i);
|
MediaTrack* tr = GetTrack(nullptr, i);
|
||||||
if (!tr) continue;
|
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');
|
std::vector<char> buf(8192, '\0');
|
||||||
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
|
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
|
||||||
continue;
|
continue;
|
||||||
@@ -199,70 +163,193 @@ static bool resolveRazorArea(ResolvedSource& out)
|
|||||||
}
|
}
|
||||||
if (allRanges.empty()) return false;
|
if (allRanges.empty()) return false;
|
||||||
reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges);
|
reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges);
|
||||||
out.startSeconds = u.startSeconds;
|
start = u.startSeconds;
|
||||||
out.endSeconds = u.endSeconds;
|
end = u.endSeconds;
|
||||||
return out.endSeconds > out.startSeconds;
|
return end > start;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatches to the right resolver for a source mode. Returns false with a reason
|
// Infers the render RANGE for any scope: razor union when a razor area is present,
|
||||||
// in `why` when there is nothing to capture (so the action can log precisely).
|
// else the time selection (pure inferRangeSource decides which). Orthogonal to
|
||||||
static bool ResolveSource(reasampler::SourceMode mode, ResolvedSource& out,
|
// scope. Returns false (with a reason) when neither yields a non-empty range.
|
||||||
std::string& why)
|
static bool resolveRange(double& start, double& end, std::string& why)
|
||||||
{
|
{
|
||||||
using reasampler::SourceMode;
|
double rzStart = 0.0, rzEnd = 0.0;
|
||||||
switch (mode)
|
const bool hasRazor = resolveRazorRange(rzStart, rzEnd);
|
||||||
|
if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor)
|
||||||
{
|
{
|
||||||
case SourceMode::MasterMix:
|
start = rzStart; end = rzEnd;
|
||||||
case SourceMode::TimeSelection:
|
return true; // resolveRazorRange already verified end > start
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
why = "unknown source mode";
|
if (resolveTimeSelection(start, end)) return true;
|
||||||
|
why = "make a razor area or a time selection first";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs one capture-action-table row: resolve its source, build a CaptureRequest,
|
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
|
||||||
// hand it to the offline backend, add the Sample to the bank, persist + mark dirty.
|
static bool collectSelectedTracks(ResolvedSource& out)
|
||||||
// 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.
|
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)
|
static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||||
{
|
{
|
||||||
ResolvedSource src;
|
ResolvedSource src;
|
||||||
std::string why;
|
std::string why;
|
||||||
if (!ResolveSource(def.sourceMode, src, why))
|
if (!ResolveScopeSource(def.scope, src, why))
|
||||||
{
|
{
|
||||||
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
|
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reasampler::CaptureRequest req;
|
reasampler::CaptureRequest req;
|
||||||
req.sourceMode = def.sourceMode;
|
req.sourceMode = reasampler::sourceModeForScope(def.scope);
|
||||||
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
||||||
req.endSeconds = src.endSeconds;
|
req.endSeconds = src.endSeconds;
|
||||||
req.wetDry = def.wetDry; // 1.0 wet (all M7 actions are wet-only)
|
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
||||||
req.renderTail = false; // exact bounds, no tail (M7 default)
|
req.renderTail = false; // exact bounds, no tail (default)
|
||||||
req.tailMs = 0.0;
|
req.tailMs = 0.0;
|
||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
|
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
|
||||||
req.baseName = def.baseName;
|
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::OfflineRenderBackend backend;
|
||||||
reasampler::CaptureResult res = backend.capture(req);
|
reasampler::CaptureResult res = backend.capture(req);
|
||||||
@@ -332,7 +419,7 @@ static void RunInsertSelected(bool conform)
|
|||||||
static bool OnHookCommand(int command, int /*flag*/)
|
static bool OnHookCommand(int command, int /*flag*/)
|
||||||
{
|
{
|
||||||
if (command == 0) return false;
|
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.
|
// 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)
|
for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i)
|
||||||
if (command == g_captureCmdIds[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("-gaccel", (void*)&g_accelToggleBankPanel);
|
||||||
g_rec->Register("-command_id",
|
g_rec->Register("-command_id",
|
||||||
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
|
(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
|
// with '-'-prefixed strings (per the contract). The FOREVER-STABLE id
|
||||||
// strings come from the same table used to register them.
|
// 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);
|
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
|
// Destroy the docked window and release cached thumbnails before we drop
|
||||||
// the API pointers (DockWindowRemove/DestroyWindow need them live).
|
// 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_hInst = hInstance;
|
||||||
g_rec = rec;
|
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.
|
// 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 —
|
// g_captureAccels must be sized BEFORE the loop and never reallocated after —
|
||||||
// REAPER holds a pointer to each element until we mirror-unregister it.
|
// REAPER holds a pointer to each element until we mirror-unregister it.
|
||||||
|
|||||||
+67
-25
@@ -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.
|
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
|
||||||
|
|
||||||
#include "render_settings.h"
|
#include "render_settings.h"
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||||
// All M7 actions are wet-only. `wetDry` is accepted so CaptureRequest.wetDry
|
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
||||||
// remains the seam for future M10 dry work, but it does not affect the mapping.
|
// 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;
|
RenderSettingsChoice c;
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
@@ -50,6 +51,49 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
|||||||
return c;
|
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> parseRazorEdits(const std::string& razorString) {
|
||||||
std::vector<RazorRange> ranges;
|
std::vector<RazorRange> ranges;
|
||||||
std::istringstream in(razorString);
|
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.
|
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
|
||||||
std::string startTok, endTok, guidTok;
|
std::string startTok, endTok, guidTok;
|
||||||
while (in >> 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 `""`.
|
// A track-audio area's GUID token is the empty quoted string `""`.
|
||||||
if (guidTok != "\"\"") continue;
|
if (guidTok != "\"\"") continue;
|
||||||
|
|
||||||
@@ -93,29 +137,27 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::vector<CaptureActionDef>& captureActionTable() {
|
const std::vector<CaptureActionDef>& captureActionTable() {
|
||||||
// Built once (function-local static): four wet-only actions. Tail OFF for all
|
// Built once (function-local static): three SCOPE actions. Tail OFF for all
|
||||||
// (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string.
|
// (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string. Each
|
||||||
// Dry variants deferred to M10; see kRenderPreFaderStems note in the header.
|
// 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 = {
|
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",
|
{"CEREBELLUM_REASAMPLER_CAPTURE_MASTER",
|
||||||
"ReaSampler: capture master mix", "master_mix",
|
"ReaSampler: capture master", "master",
|
||||||
SourceMode::MasterMix, 1.0},
|
CaptureScope::Master},
|
||||||
|
|
||||||
// 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},
|
|
||||||
};
|
};
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-36
@@ -1,16 +1,22 @@
|
|||||||
#pragma once
|
#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
|
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||||
// vendor/ includes. Standard library only. The capture shell (capture.cpp) reads
|
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
|
||||||
// the actual DAW state (time selection, selected tracks/items, razor strings) and
|
// action layer (main.cpp) read the actual DAW state (time selection, selected
|
||||||
// hands the raw values here so the three genuinely-pure, easy-to-get-wrong pieces
|
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
|
||||||
// are unit-tested outside the DAW:
|
// 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.
|
// 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) —
|
// 3. range inference: razor-present -> razor union, else time selection. Range
|
||||||
// the taxonomy, in one place so main.cpp iterates it instead of hand-listing.
|
// 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
|
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
|
||||||
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
|
// 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) --
|
// --- 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.
|
// 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 kRenderMasterMix = 0; // (&(1|2))==0, no source bits
|
||||||
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
||||||
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
||||||
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
||||||
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
||||||
// NOTE: kRenderPreFaderStems (&8192) is NOT used in M7. REAPER offline render has
|
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no
|
||||||
// no true pre-FX "dry" bit. Pre-fader stems are post-FX/pre-fader-volume — an
|
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
||||||
// approximation, not a dry capture. True pre-FX dry requires FX-bypass-around-
|
// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that
|
||||||
// render or the M8 realtime pre-FX path; it will be designed with the M10 null
|
// fall outside a scope — NOT by any render bit. All capture actions render wet
|
||||||
// test. All M7 capture actions are wet-only.
|
// (post the FX that remain enabled); the scope decides which FX remain enabled.
|
||||||
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
|
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
|
||||||
|
|
||||||
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
||||||
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
||||||
struct RenderSettingsChoice {
|
struct RenderSettingsChoice {
|
||||||
int settings = kRenderMasterMix;
|
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.
|
// Maps a source mode to its RENDER_SETTINGS value (which content the render
|
||||||
// All M7 actions are wet-only (post-FX). `wetDry` is accepted but ignored for
|
// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits.
|
||||||
// the mapping — retained in CaptureRequest as the seam for future M10 dry work.
|
// `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):
|
// CONFIRMED (SDK header ~3041):
|
||||||
// MasterMix / TimeSelection -> master mix (0).
|
// MasterMix / TimeSelection -> master mix (0).
|
||||||
@@ -57,8 +64,60 @@ struct RenderSettingsChoice {
|
|||||||
// RazorArea -> &4096| single-file.
|
// RazorArea -> &4096| single-file.
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
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 —
|
// 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 {
|
struct RazorRange {
|
||||||
double startSeconds = 0.0;
|
double startSeconds = 0.0;
|
||||||
double endSeconds = 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
|
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
|
||||||
// GUID like {…} for an envelope-lane area.
|
// GUID like {…} for an envelope-lane area.
|
||||||
//
|
//
|
||||||
// Returns only the track-audio ranges (envelope-lane triples are skipped — M7
|
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
|
||||||
// renders track audio). Malformed/short trailing tokens are ignored, not fatal.
|
// 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).
|
// A range with end <= start is dropped (no negative/empty areas leak through).
|
||||||
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
|
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) -----------
|
// --- 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
|
// One row per bindable SCOPE action. Three scopes (item / track / master); the
|
||||||
// for every row (exact bounds); a tail-on variant is a later opt-in, YAGNI now.
|
// range each captures (razor-else-time) is inferred at fire time, not a mode.
|
||||||
// Yields a bounded, discoverable set with NO dialogs (the tool's no-clutter ethos).
|
// 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
|
// commandString is FOREVER-STABLE (user keybindings key off it) — never change a
|
||||||
// shipped value. baseName feeds the file stem (sanitized by capture_paths).
|
// 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 {
|
struct CaptureActionDef {
|
||||||
const char* commandString; // CEREBELLUM_REASAMPLER_… FOREVER-STABLE id string
|
const char* commandString; // CEREBELLUM_REASAMPLER_… FOREVER-STABLE id string
|
||||||
const char* description; // Actions-list label
|
const char* description; // Actions-list label
|
||||||
const char* baseName; // file-stem base for this capture
|
const char* baseName; // file-stem base for this capture
|
||||||
SourceMode sourceMode;
|
CaptureScope scope; // FX scope (item / track / master)
|
||||||
double wetDry; // 1.0 (wet) for all M7 rows; seam for M10 dry
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The full M7 capture-action table. Iterated by main.cpp to register the family
|
// The capture-action table. Iterated by main.cpp to register the family and route
|
||||||
// and route each fired command back to its definition. Kept here (pure) so the
|
// each fired command back to its definition. Kept here (pure) so the taxonomy is
|
||||||
// taxonomy is one testable list, not scattered registration code.
|
// one testable list, not scattered registration code.
|
||||||
//
|
//
|
||||||
// Four wet-only rows: CAPTURE_MASTER, CAPTURE_TRACKS_WET, CAPTURE_ITEMS_WET,
|
// Three scope rows: CAPTURE_ITEM, CAPTURE_TRACK, CAPTURE_MASTER. This replaces the
|
||||||
// CAPTURE_RAZOR_WET. Dry variants are deferred to M10 (null-test work) — see the
|
// M7 four-mode table (master / tracks / items / razor) — razor is now an inferred
|
||||||
// kRenderPreFaderStems note above for why offline dry is non-trivial.
|
// range, not a mode, and each scope enforces its FX-scope invariant via
|
||||||
|
// fxBypassPlanFor.
|
||||||
const std::vector<CaptureActionDef>& captureActionTable();
|
const std::vector<CaptureActionDef>& captureActionTable();
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Standalone tests for reasampler::render_settings — no REAPER, no framework.
|
// Standalone tests for reasampler::render_settings — no REAPER, no framework.
|
||||||
// Covers the three pure pieces behind the M7 capture family: the source-mode ->
|
// Covers the pure pieces behind the three-scope capture family: the source-mode ->
|
||||||
// RENDER_SETTINGS bit mapping (wet-only), P_RAZOREDITS parsing -> ranges + union,
|
// RENDER_SETTINGS bit mapping, P_RAZOREDITS parsing -> ranges + union, scope ->
|
||||||
// and the capture-action taxonomy table (stable ids, coverage of every mode).
|
// 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"
|
#include "../src/render_settings.h"
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ static void testMasterMixWet() {
|
|||||||
CHECK(c.supported);
|
CHECK(c.supported);
|
||||||
// TimeSelection aliases master mix — same result.
|
// TimeSelection aliases master mix — same result.
|
||||||
CHECK(renderSettingsFor(SourceMode::TimeSelection, 1.0).settings == kRenderMasterMix);
|
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.
|
// must still yield the same wet master-mix bits.
|
||||||
CHECK(renderSettingsFor(SourceMode::MasterMix, 0.0).settings == kRenderMasterMix);
|
CHECK(renderSettingsFor(SourceMode::MasterMix, 0.0).settings == kRenderMasterMix);
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,7 @@ static void testParseMultipleAreas() {
|
|||||||
|
|
||||||
static void testParseSkipsEnvelopeLaneAreas() {
|
static void testParseSkipsEnvelopeLaneAreas() {
|
||||||
// A triple whose GUID is a real {…} is an ENVELOPE-lane area — skipped, since
|
// 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(
|
auto r = parseRazorEdits(
|
||||||
"1.0 2.0 \"\" 3.0 4.0 {AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}");
|
"1.0 2.0 \"\" 3.0 4.0 {AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}");
|
||||||
CHECK(r.size() == 1);
|
CHECK(r.size() == 1);
|
||||||
@@ -106,40 +108,94 @@ static void testRazorUnionBounds() {
|
|||||||
CHECK(empty.startSeconds == 0.0 && empty.endSeconds == 0.0);
|
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();
|
const auto& table = captureActionTable();
|
||||||
// Exactly 4 wet-only rows: MASTER, TRACKS_WET, ITEMS_WET, RAZOR_WET.
|
// Exactly 3 scope rows: item, track, master.
|
||||||
CHECK(table.size() == 4);
|
CHECK(table.size() == 3);
|
||||||
|
|
||||||
std::set<std::string> ids;
|
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) {
|
for (const auto& def : table) {
|
||||||
// Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE
|
// Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE
|
||||||
// (duplicate ids would collide on registration).
|
// (duplicate ids would collide on registration).
|
||||||
std::string id = def.commandString;
|
std::string id = def.commandString;
|
||||||
CHECK(id.rfind("CEREBELLUM_REASAMPLER_", 0) == 0);
|
CHECK(id.rfind("CEREBELLUM_REASAMPLER_", 0) == 0);
|
||||||
CHECK(ids.insert(id).second); // false if duplicate
|
CHECK(ids.insert(id).second); // false if duplicate
|
||||||
// Every row is wet (>=0.5) and a real offline source.
|
// Every scope resolves to a supported offline source.
|
||||||
CHECK(def.wetDry >= 0.5);
|
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
|
||||||
CHECK(renderSettingsFor(def.sourceMode, def.wetDry).supported);
|
|
||||||
|
|
||||||
if (def.sourceMode == SourceMode::MasterMix) sawMaster = true;
|
if (def.scope == CaptureScope::Item) sawItem = true;
|
||||||
if (def.sourceMode == SourceMode::SelectedTracks) sawTracks = true;
|
if (def.scope == CaptureScope::Track) sawTrack = true;
|
||||||
if (def.sourceMode == SourceMode::SelectedItems) sawItems = true;
|
if (def.scope == CaptureScope::Master) sawMaster = true;
|
||||||
if (def.sourceMode == SourceMode::RazorArea) sawRazor = true;
|
|
||||||
}
|
}
|
||||||
|
CHECK(sawItem);
|
||||||
|
CHECK(sawTrack);
|
||||||
CHECK(sawMaster);
|
CHECK(sawMaster);
|
||||||
CHECK(sawTracks);
|
|
||||||
CHECK(sawItems);
|
|
||||||
CHECK(sawRazor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testNoDryRowsInTable() {
|
static void testMasterCommandIdIsPreserved() {
|
||||||
// M7 ships wet-only. No table row must have wetDry < 0.5.
|
// 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())
|
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() {
|
int main() {
|
||||||
@@ -153,8 +209,13 @@ int main() {
|
|||||||
testParseSkipsEnvelopeLaneAreas();
|
testParseSkipsEnvelopeLaneAreas();
|
||||||
testParseEmptyAndMalformed();
|
testParseEmptyAndMalformed();
|
||||||
testRazorUnionBounds();
|
testRazorUnionBounds();
|
||||||
testTableHasFourWetOnlyRows();
|
testScopeSourceModes();
|
||||||
testNoDryRowsInTable();
|
testRangeInference();
|
||||||
|
testItemScopeBypassesEverythingButTake();
|
||||||
|
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
||||||
|
testMasterScopeBypassesNothing();
|
||||||
|
testTableHasThreeScopeRows();
|
||||||
|
testMasterCommandIdIsPreserved();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("render_settings: all tests passed\n");
|
if (g_fail == 0) std::printf("render_settings: all tests passed\n");
|
||||||
else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);
|
else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);
|
||||||
|
|||||||
Reference in New Issue
Block a user