From d4ffea6918e99e545d74fac07e06e7c9e1eabda2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 13:34:51 -0400 Subject: [PATCH] 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