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.
This commit is contained in:
2026-07-23 13:34:51 -04:00
parent df03b5e759
commit d4ffea6918
2 changed files with 102 additions and 30 deletions
+94 -24
View File
@@ -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<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.
// 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);