From df03b5e759c5c6da50bb7c168466a62428171629 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 13:12:36 -0400 Subject: [PATCH 1/2] 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. --- CMakeLists.txt | 1 - src/capture.cpp | 9 +- src/capture.h | 14 +- src/main.cpp | 327 +++++++++++++++++++++------------ src/render_settings.cpp | 92 +++++++--- src/render_settings.h | 131 +++++++++---- tests/test_render_settings.cpp | 111 ++++++++--- 7 files changed, 470 insertions(+), 215 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ea6285a..c4d9263 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/src/capture.cpp b/src/capture.cpp index ddcd539..b4ebeae 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -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 diff --git a/src/capture.h b/src/capture.h index 3e2753e..4be8aa6 100644 --- a/src/capture.h +++ b/src/capture.h @@ -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; diff --git a/src/main.cpp b/src/main.cpp index 67ed03c..4700831 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 g_captureCmdIds; static std::vector g_captureAccels; +// Retired capture-action command-id strings (M7 four-mode family). Kept ONLY to +// mirror-unregister them on unload so a user's stale keybindings are cleaned up. +// Never re-register these. CAPTURE_MASTER is NOT here — its id string carries over +// to the new master scope action unchanged. +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 trackGuids; // populated only for SelectedTracks + double startSeconds = 0.0; + double endSeconds = 0.0; + std::vector sourceTracks; // item's/selected tracks; empty for master + std::vector 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 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 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& 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 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. diff --git a/src/render_settings.cpp b/src/render_settings.cpp index a66997d..148fd2c 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -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 parseRazorEdits(const std::string& razorString) { std::vector ranges; std::istringstream in(razorString); @@ -59,7 +103,7 @@ std::vector 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& ranges) { } const std::vector& 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 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; } diff --git a/src/render_settings.h b/src/render_settings.h index 4a1f6fb..86ceee0 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -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 parseRazorEdits(const std::string& razorString); @@ -81,28 +140,28 @@ RazorRange razorUnionBounds(const std::vector& 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& captureActionTable(); } // namespace reasampler diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 14d4800..f5f7865 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -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 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); From d4ffea6918e99e545d74fac07e06e7c9e1eabda2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 13:34:51 -0400 Subject: [PATCH 2/2] feat(capture): neutralize parent/master gain, pan, width, law in FX-scope capture FxBypassGuard now snapshots/restores D_VOL, D_PAN, D_WIDTH, D_PANLAW, I_PANMODE alongside I_FXEN for every bypassed track (forcing stereo-pan mode for an unambiguous render), so Track/Item captures are uncolored by parent/folder/master fader and routing. All restored on every path. --- src/main.cpp | 118 +++++++++++++++++++++++++++++++++--------- src/render_settings.h | 14 ++--- 2 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 4700831..8cbdb12 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -250,13 +250,42 @@ static bool ResolveScopeSource(reasampler::CaptureScope scope, 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. +// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) -- +// For every track a scope must NOT hear the FX of, this ALSO neutralizes that +// track's fader gain AND its full pan chain (pan/width/law/mode) for the render — +// because a Track/Item capture renders via master and would otherwise sum through +// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan +// coloring into the file (Daniel: the capture is likely re-routed through that +// same chain later, so parent/master level and pan must not be baked in). The +// neutralize set is IDENTICAL to the FX-bypass set: +// Item -> own track + all ancestors + master (take vol/pan kept: item content). +// Track -> all ancestors + master (selected track's OWN vol/pan kept). +// Master-> nothing (full chain, unchanged). +// +// Per track in that set we snapshot & set the full parent-chain-independence set, +// so a Track/Item capture is uncolored by the parent/folder/master it renders +// through — no FX, no fader, and no pan/width/law/mode coloring: +// I_FXEN -> 0 (FX bypassed; SDK ~2194) +// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB") +// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1") +// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1", +// 1.0 = full width = no narrowing/collapse) +// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain) +// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual") +// All are restored to their ORIGINAL values on EVERY exit path (RAII). +// +// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes +// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan) +// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR +// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the +// header does not state as such. Rather than snapshot two more mode-conditional +// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the +// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the +// original mode. This fully neutralizes pan for every original mode with no +// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.) +// +// Structurally non-destructive: no takes, no items, no project restructuring — +// only transient FX-enable + trim-volume toggles, always restored. class FxBypassGuard { public: @@ -284,8 +313,10 @@ public: } 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. + // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass() + // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it + // just like any other in-scope track; only the master's summing/routing + // topology (the mix bus itself) remains — that is not a per-track param. if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); } } @@ -293,36 +324,73 @@ public: ~FxBypassGuard() { // Restore in reverse for symmetry (order is not load-bearing — each track - // appears once, snapshots are independent). + // appears once, snapshots are independent). EVERY snapshotted param is + // restored to its ORIGINAL value on this (every) exit path. Restore + // I_PANMODE before the pan values so any mode-conditional params (e.g. dual + // pan) settle under the original mode. for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) + { SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); + SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol); + SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode); + SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan); + SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width); + SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw); + } } FxBypassGuard(const FxBypassGuard&) = delete; FxBypassGuard& operator=(const FxBypassGuard&) = delete; private: - struct Snap { MediaTrack* track; double fxen; }; + // One snapshot per bypassed track: all params we neutralize, at their originals. + // panmode captures I_PANMODE so we can force stereo-pan for the render and put + // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when + // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to + // touch or guess neutral values for them. + struct Snap + { + MediaTrack* track; + double fxen; + double vol; + double pan; + double width; + double panlaw; + double panmode; + }; std::vector 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. + // Snapshot every neutralized param once per track (dedup: an ancestor shared by + // two selected tracks must be restored to its ORIGINAL values, not to a + // re-snapshot of the already-neutralized state), then read ALL originals, push + // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain. 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) + // Read ALL originals first (atomic snapshot), then push, then neutralize. + const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); + const double vol = GetMediaTrackInfo_Value(tr, "D_VOL"); + const double pan = GetMediaTrackInfo_Value(tr, "D_PAN"); + const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH"); + const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW"); + const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE"); + snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode}); + SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) + SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226) + SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231) + SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227) + SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228) + SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232) } }; // 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. +// clear the out-of-scope FX AND neutralize their fader gain + pan chain (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 + fader gain + pan/width/law/mode are fully restored +// on every path by FxBypassGuard, and the backend restores every RENDER_* setting. static void RunCapture(const reasampler::CaptureActionDef& def) { ResolvedSource src; @@ -346,8 +414,10 @@ static void RunCapture(const reasampler::CaptureActionDef& def) req.baseName = def.baseName; 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. + // Bypass the out-of-scope FX and neutralize their fader gain to unity for the + // duration of the render (so parent/master fader level is not baked into the + // file). 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); diff --git a/src/render_settings.h b/src/render_settings.h index 86ceee0..1e555b6 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -102,12 +102,14 @@ RangeSource inferRangeSource(bool hasRazorArea); // 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. +// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan. +// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN +// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item +// capture rendered via master does NOT bake in the parent/folder/master fader +// level (Daniel: the capture is likely re-routed through that chain later). PAN +// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean +// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan +// selects the SET; the guard applies both the FX bypass and the gain neutralize. struct FxBypassPlan { bool bypassSelfFx = false; // the captured track's own FX bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX