capture: refuse the multi-track ranged item render, silence the track's children and receives for it, and gate every exact-bounds capture on its frame count

This commit is contained in:
2026-08-01 20:58:33 -04:00
parent 5e3ea6c851
commit 7dc80e7a4f
20 changed files with 525 additions and 27 deletions
+11 -2
View File
@@ -51,7 +51,8 @@ Detail specific to these pure modules:
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition``NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number a capture's file must match) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
## Gotchas
@@ -68,7 +69,15 @@ Detail specific to these pure modules:
item captured the whole item), NOT a header-confirmed fact. It is why
`sourceModeForScope` routes item scope to `&32` only when the item extent
already IS the requested window — do not re-point item scope unconditionally at
`&32`, and do not widen the `&32` branch to windows it cannot express.
`&32`, and do not widen the `&32` branch to windows it cannot express. This is the
one home for that inference; the sites that act on it point here rather than
restating it.
- **The re-source changes the CONTENT, not the FX scope.** `fxBypassPlanFor` is keyed
on `CaptureScope`, so a ranged item capture still hears take/item FX only — but the
selected-tracks source prints everything upstream of the track. The shell answers
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
`src/shell/capture/CLAUDE.md`.
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
FX-bypass-around-render mechanism, never by a render bit.
+3
View File
@@ -10,6 +10,9 @@ reasampler_test(render_settings LINK render_settings)
reasampler_pure_library(render_window SOURCES render_window.cpp)
reasampler_test(render_window LINK render_window)
reasampler_pure_library(track_topology SOURCES track_topology.cpp)
reasampler_test(track_topology LINK track_topology)
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
reasampler_test(batch_capture LINK batch_capture)
+7
View File
@@ -112,6 +112,13 @@ SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
return SourceMode::SelectedTracks;
}
bool isMultiTrackRangedItemRender(CaptureScope scope, SourceMode mode,
int sourceTrackCount) {
return scope == CaptureScope::Item
&& mode == SourceMode::SelectedTracks
&& sourceTrackCount > 1;
}
RangeSource inferRangeSource(bool hasRazorArea) {
// Razor wins when present; otherwise the time selection. Orthogonal to scope.
return hasRazorArea ? RangeSource::Razor : RangeSource::TimeSelection;
+26 -6
View File
@@ -110,14 +110,34 @@ enum class CaptureScope {
//
// Item scope captures the selected items ONLY when `itemExtentIsWindow` — i.e.
// when those items' own extent already prints the requested window (see
// render_window::itemExtentPrintsWindow). REAPER's selected-items render source
// derives the render's bounds from the item extents, so a window strictly inside
// (or wider than) a selected item cannot be expressed through it; that case
// renders time-bounded through the items' own tracks. The FX scope is unaffected
// either way — fxBypassPlanFor is keyed on CaptureScope, not on the source mode,
// so an item capture still hears take/item FX only.
// render_window::itemExtentPrintsWindow). REAPER's selected-items render source is
// INFERRED to derive its bounds from the item extents, so a window strictly inside
// (or wider than) a selected item cannot be expressed through it; that case renders
// time-bounded through the items' own tracks. The inference is unverified — see
// src/core/capture/CLAUDE.md §Gotchas for what it rests on.
//
// The FX SCOPE is unaffected by the swap (fxBypassPlanFor is keyed on CaptureScope,
// not on the source mode, so an item capture still hears take/item FX only), but the
// CONTENT reaching the render is not: the selected-tracks source prints everything
// upstream of the track — its folder children and its receives — which the shell
// transiently silences (shell/capture/render_isolation). An overlapping item on the
// track ITSELF is deliberately not isolated; see src/shell/capture/CLAUDE.md.
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
// True for the one render shape that cannot land as a single capture: an item-scope
// capture re-sourced to the selected-tracks render (its window is not the item
// extent) whose selected items span more than one track. That source is read as
// rendering one file per selected track — the single-file bit is documented for
// item/razor sources only (SDK header ~3041), which is the whole basis for the
// reading and is DAW-unverified. If it holds, N tracks collapse N stems onto one
// literal render pattern and whichever file survived would land as a successful
// capture carrying one track's audio. The caller refuses instead.
//
// Track scope is deliberately NOT covered here even though it renders through the
// same source — see src/shell/capture/CLAUDE.md §Gotchas.
bool isMultiTrackRangedItemRender(CaptureScope scope, SourceMode mode,
int sourceTrackCount);
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
//
// Razor-present -> razor union; otherwise time selection. Razor is a range
+10 -4
View File
@@ -11,15 +11,21 @@ namespace reasampler::capture {
// is a difference of frame indices rather than a rounded duration — two windows
// of equal length at different offsets can legitimately differ by one frame.
// Returns 0 for a non-positive rate or an empty/inverted window.
//
// The offline backend compares this against the rendered file's own frame count, so
// exact-bounds failures surface as a refused capture rather than a wrong file. That
// REAPER resolves the two edges the same way is UNVERIFIED — a DAW pass decides
// whether the equality is exact or off by a frame.
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
// True when a render bounded by the selected items' own extent
// [itemStart, itemEnd) already prints exactly the requested
// [reqStart, reqEnd) window — the one case where REAPER's selected-items render
// source needs no correction. Compared at frame resolution, because a sub-frame
// difference prints the same frames. An unknown rate (<= 0) falls back to exact
// equality, which can only send a window to the time-bounded render, never widen
// one.
// source is believed to need no correction (the bounds-override inference behind
// that is unverified; src/core/capture/CLAUDE.md §Gotchas states what it rests on).
// Compared at frame resolution, because a sub-frame difference prints the same
// frames. An unknown rate (<= 0) falls back to exact equality, which can only send
// a window to the time-bounded render, never widen one.
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate);
+28
View File
@@ -0,0 +1,28 @@
// track_topology.cpp — see the header.
#include "core/capture/track_topology.h"
#include <cstddef>
namespace reasampler::capture {
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
int parentIndex) {
std::vector<int> children;
const int count = static_cast<int>(folderDepths.size());
if (parentIndex < 0 || parentIndex >= count) return children;
if (folderDepths[static_cast<std::size_t>(parentIndex)] != 1) return children;
// Depth relative to the parent: 1 immediately after it (inside its folder), and
// 0 once the folder closes. Only tracks sitting at relative depth 1 are direct
// children; a child that opens its own folder pushes the level to 2, which is
// what excludes its descendants.
int level = 1;
for (int i = parentIndex + 1; i < count && level > 0; ++i) {
if (level == 1) children.push_back(i);
level += folderDepths[static_cast<std::size_t>(i)];
}
return children;
}
} // namespace reasampler::capture
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// track_topology — pure folder arithmetic over a project's track list: which tracks
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
// tests/test_track_topology.cpp.
#include <vector>
namespace reasampler::capture {
// Indices of `parentIndex`'s DIRECT children, given every track's I_FOLDERDEPTH in
// track order. I_FOLDERDEPTH is a DELTA applied AFTER its own track (SDK header
// ~2215: 0 = normal, 1 = opens a folder, -n = closes n folders), so the depth walk
// below is the only way to recover the tree from the flat list.
//
// Empty when `parentIndex` is out of range or its track does not open a folder.
// Grandchildren are deliberately excluded: their audio reaches the parent only
// through the direct child that owns them, so a caller silencing each direct child's
// send-to-parent silences the whole subtree. An unterminated folder (no closing
// negative delta) treats every remaining track as inside it, matching REAPER.
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
int parentIndex);
} // namespace reasampler::capture