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
+42 -5
View File
@@ -216,11 +216,12 @@ static void testScopeSourceModes() {
}
static void testRangedItemScopeRendersTimeBounded() {
// The Ψ.7 defect pinned at the bit level. A window the item extent does NOT
// print must never reach the &32 selected-items source: that source takes its
// bounds from the item extents, so RENDER_STARTPOS/ENDPOS cannot narrow it and
// the capture widens to the whole item. The ranged item capture renders through
// the selected-tracks source instead, which IS time-bounded.
// The widening defect pinned at the bit level. A window the item extent does NOT
// print must never reach the &32 selected-items source: that source is INFERRED
// (unverified — src/core/capture/CLAUDE.md §Gotchas) to take its bounds from the
// item extents, so RENDER_STARTPOS/ENDPOS cannot narrow it and the capture widens
// to the whole item. The ranged item capture renders through the selected-tracks
// source instead, which IS time-bounded.
const SourceMode ranged = sourceModeForScope(CaptureScope::Item, false);
CHECK(ranged == SourceMode::SelectedTracks);
@@ -242,6 +243,41 @@ static void testRangedItemScopeRendersTimeBounded() {
CHECK(fxBypassPlanFor(CaptureScope::Item).bypassSelfFx);
}
static void testMultiTrackRangedItemRenderIsNamedForRefusal() {
// The one shape that cannot land: a ranged item capture (item scope re-sourced to
// the selected-tracks render) whose items span more than one track. That source
// renders one file per track with no single-file bit available, so N stems would
// collapse onto one render pattern and one track's audio would land as a
// successful capture.
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 2));
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 7));
// One track is the whole point of the re-source — it must still render.
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 1));
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 0));
// A full-extent item capture keeps the selected-items source, whose single-file
// bit already sums a multi-track item selection into one file.
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedItems, 3));
// Track scope renders through the same source and is deliberately untouched here
// — that per-track output predates the item re-source and is filed in docs/TODO.md.
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Track,
SourceMode::SelectedTracks, 3));
// The predicate is reachable from the mapping it guards: the ranged item capture's
// own source mode is the one it names.
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
sourceModeForScope(CaptureScope::Item, false), 2));
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
sourceModeForScope(CaptureScope::Item, true), 2));
}
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
static void testRangeInference() {
@@ -339,6 +375,7 @@ int main() {
testRazorUnionBounds();
testScopeSourceModes();
testRangedItemScopeRendersTimeBounded();
testMultiTrackRangedItemRenderIsNamedForRefusal();
testRangeInference();
testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
+83
View File
@@ -0,0 +1,83 @@
// Standalone tests for reasampler::track_topology — no REAPER, no framework.
// Covers the direct-child walk over a flat I_FOLDERDEPTH delta list: the set a
// ranged item render must silence so a folder parent's children stay out of the
// capture.
#include "../src/core/capture/track_topology.h"
#include <cstdio>
#include <vector>
using namespace reasampler::capture;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool sameIndices(const std::vector<int>& got, const std::vector<int>& want) {
return got == want;
}
static void testFlatProjectHasNoChildren() {
// No track opens a folder, so no track has children — including the one asked
// about. A flat project must produce an empty silence set, not a stray one.
const std::vector<int> depths{0, 0, 0};
CHECK(sameIndices(directChildIndices(depths, 0), {}));
CHECK(sameIndices(directChildIndices(depths, 1), {}));
}
static void testFolderParentReturnsItsDirectChildren() {
// 0: folder parent, 1: child, 2: last child (closes the folder), 3: unrelated.
const std::vector<int> depths{1, 0, -1, 0};
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
// Track 3 sits outside the folder entirely and owns nothing.
CHECK(sameIndices(directChildIndices(depths, 3), {}));
}
static void testGrandchildrenAreExcluded() {
// 0: outer folder, 1: inner folder (a direct child), 2: grandchild closing the
// inner folder, 3: second direct child closing the outer. The grandchild reaches
// track 0 only through track 1, so silencing track 1's send covers it — listing
// it too would be redundant, and the walk must not.
const std::vector<int> depths{1, 1, -1, -1};
CHECK(sameIndices(directChildIndices(depths, 0), {1, 3}));
// Asked about the inner folder, the grandchild IS the direct child.
CHECK(sameIndices(directChildIndices(depths, 1), {2}));
}
static void testMultiLevelCloseEndsTheOuterFolderToo() {
// A -2 closes two folders at once (SDK: "last in the innermost and next-innermost
// folders"), so track 3 is outside track 0's folder even though no track between
// them closed it singly.
const std::vector<int> depths{1, 1, -2, 0};
CHECK(sameIndices(directChildIndices(depths, 0), {1}));
CHECK(sameIndices(directChildIndices(depths, 1), {2}));
}
static void testNonFolderAndOutOfRangeReturnEmpty() {
const std::vector<int> depths{1, 0, -1};
CHECK(sameIndices(directChildIndices(depths, 1), {})); // a plain child
CHECK(sameIndices(directChildIndices(depths, 2), {})); // the folder's last track
CHECK(sameIndices(directChildIndices(depths, -1), {})); // no such track
CHECK(sameIndices(directChildIndices(depths, 3), {})); // past the end
CHECK(sameIndices(directChildIndices({}, 0), {})); // empty project
}
static void testUnterminatedFolderSwallowsTheRest() {
// A folder that never closes owns every remaining track, which is how REAPER
// reads the same list — the walk must not stop early and leave a child audible.
const std::vector<int> depths{1, 0, 0};
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
}
int main() {
testFlatProjectHasNoChildren();
testFolderParentReturnsItsDirectChildren();
testGrandchildrenAreExcluded();
testMultiLevelCloseEndsTheOuterFolderToo();
testNonFolderAndOutOfRangeReturnEmpty();
testUnterminatedFolderSwallowsTheRest();
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}