Merge Ψ-W1-T1: a ranged item capture renders the window, with children and receives silenced for it
# Conflicts: # docs/TODO.md # src/app/CMakeLists.txt
This commit is contained in:
@@ -203,14 +203,80 @@ static void testRazorUnionBounds() {
|
||||
// --- sourceModeForScope: scope -> render source mode -------------------------
|
||||
|
||||
static void testScopeSourceModes() {
|
||||
// Each scope drives a distinct render source. Item -> items, Track -> tracks.
|
||||
// (There is no master scope — to capture the master you render a track.) These
|
||||
// feed renderSettingsFor and must be supported.
|
||||
CHECK(sourceModeForScope(CaptureScope::Item) == SourceMode::SelectedItems);
|
||||
CHECK(sourceModeForScope(CaptureScope::Track) == SourceMode::SelectedTracks);
|
||||
// Track scope always renders its selected tracks (there is no master scope — to
|
||||
// capture the master you render a track), whatever the window.
|
||||
CHECK(sourceModeForScope(CaptureScope::Track, true) == SourceMode::SelectedTracks);
|
||||
CHECK(sourceModeForScope(CaptureScope::Track, false) == SourceMode::SelectedTracks);
|
||||
// Item scope renders the selected items only when their extent already prints
|
||||
// the requested window.
|
||||
CHECK(sourceModeForScope(CaptureScope::Item, true) == SourceMode::SelectedItems);
|
||||
// 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::Item, true), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track, true), 1.0).supported);
|
||||
}
|
||||
|
||||
static void testRangedItemScopeRendersTimeBounded() {
|
||||
// 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);
|
||||
|
||||
const RenderSettingsChoice c = renderSettingsFor(ranged, 1.0);
|
||||
CHECK(c.supported);
|
||||
CHECK((c.settings & kRenderSelItems) == 0); // the widening bit is absent
|
||||
CHECK((c.settings & kRenderSingleFile) == 0); // and its single-file companion
|
||||
CHECK(c.settings == kRenderSelTracksViaMaster);
|
||||
|
||||
// The regression floor, at the same resolution: an item capture whose window IS
|
||||
// the item extent still renders through &32 | single-file, unchanged.
|
||||
const RenderSettingsChoice floorCase =
|
||||
renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0);
|
||||
CHECK((floorCase.settings & kRenderSelItems) != 0);
|
||||
CHECK((floorCase.settings & kRenderSingleFile) != 0);
|
||||
|
||||
// FX scope is orthogonal to the re-source: a ranged item capture still hears
|
||||
// take/item FX only (this is what makes the swap safe).
|
||||
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
|
||||
// is INFERRED to render one file per track with no single-file bit available
|
||||
// (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that inference
|
||||
// rests on), 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) -----------------
|
||||
@@ -262,8 +328,10 @@ static void testTableHasBothScopes() {
|
||||
// double-prefix bug, so assert its ABSENCE.
|
||||
CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0);
|
||||
CHECK(ids.insert(suffix).second); // false if duplicate
|
||||
// Every scope resolves to a supported offline source.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
|
||||
// Every scope resolves to a supported offline source, on BOTH the
|
||||
// extent-prints-the-window path and the time-bounded one.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope, true), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope, false), 1.0).supported);
|
||||
|
||||
if (def.scope == CaptureScope::Item) ++item;
|
||||
if (def.scope == CaptureScope::Track) ++track;
|
||||
@@ -307,6 +375,8 @@ int main() {
|
||||
testParseEmptyAndMalformed();
|
||||
testRazorUnionBounds();
|
||||
testScopeSourceModes();
|
||||
testRangedItemScopeRendersTimeBounded();
|
||||
testMultiTrackRangedItemRenderIsNamedForRefusal();
|
||||
testRangeInference();
|
||||
testItemScopeBypassesEverythingButTake();
|
||||
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Standalone tests for reasampler::render_window — no REAPER, no framework.
|
||||
// Covers the bounds-equality number (a window's exact frame count at the project
|
||||
// rate) and the predicate that decides whether REAPER's selected-items render
|
||||
// source can express a requested window at all.
|
||||
|
||||
#include "../src/core/capture/render_window.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
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)
|
||||
|
||||
// --- frameCountFor: the bounds equality, stated as a number ------------------
|
||||
|
||||
static void testFrameCountIsExactNotRounded() {
|
||||
// A 1.5 s window at 48 kHz is exactly 72000 frames — the number a capture of
|
||||
// that range must produce. No rounding slack in either direction.
|
||||
CHECK(frameCountFor(2.0, 3.5, 48000) == 72000);
|
||||
// The same duration at a different offset still counts the same frames when
|
||||
// both edges are frame-aligned.
|
||||
CHECK(frameCountFor(10.0, 11.5, 48000) == 72000);
|
||||
// 44.1 kHz: 0.5 s = 22050 frames.
|
||||
CHECK(frameCountFor(1.0, 1.5, 44100) == 22050);
|
||||
}
|
||||
|
||||
static void testFrameCountIsADifferenceOfIndicesNotADuration() {
|
||||
// Both edges land mid-frame at 100 Hz (0.005 s = half a frame). Rounding the
|
||||
// DURATION would give 1 frame; rounding each EDGE gives 0.005 -> frame 1 and
|
||||
// 0.015 -> frame 2, i.e. 1 frame. Shift the window so the edges round apart
|
||||
// and the count changes — the property that makes this a window, not a length.
|
||||
CHECK(frameCountFor(0.005, 0.015, 100) == 1);
|
||||
CHECK(frameCountFor(0.004, 0.016, 100) == 2);
|
||||
}
|
||||
|
||||
static void testFrameCountRefusesEmptyInvertedAndUnknownRate() {
|
||||
CHECK(frameCountFor(3.0, 3.0, 48000) == 0); // empty
|
||||
CHECK(frameCountFor(3.0, 1.0, 48000) == 0); // inverted
|
||||
CHECK(frameCountFor(1.0, 2.0, 0) == 0); // rate unknown
|
||||
CHECK(frameCountFor(1.0, 2.0, -1) == 0); // rate nonsensical
|
||||
}
|
||||
|
||||
// --- itemExtentPrintsWindow: can the selected-items source express this? -----
|
||||
|
||||
static void testRangeInsideItemCannotBeExpressed() {
|
||||
// The defect this whole module exists for: a 1 s selection inside a 30 s item.
|
||||
// The selected-items source would print the item's 30 s, not the 1 s asked for,
|
||||
// so the capture must NOT take that path.
|
||||
CHECK(!itemExtentPrintsWindow(5.0, 6.0, /*item*/ 0.0, 30.0, 48000));
|
||||
}
|
||||
|
||||
static void testRangeWiderThanItemCannotBeExpressedEither() {
|
||||
// The same violation in the other direction: a 10 s selection over a 6 s item
|
||||
// would print 6 s. Under-printing is a bounds violation exactly as much as
|
||||
// over-printing is.
|
||||
CHECK(!itemExtentPrintsWindow(0.0, 10.0, /*item*/ 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testEachEdgeAloneDisqualifies() {
|
||||
// Matching start, drifting end.
|
||||
CHECK(!itemExtentPrintsWindow(2.0, 8.0, 2.0, 9.0, 48000));
|
||||
// Matching end, drifting start.
|
||||
CHECK(!itemExtentPrintsWindow(2.0, 8.0, 1.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testExtentEqualToWindowIsExpressible() {
|
||||
// The regression floor: a capture whose range IS the item's extent keeps the
|
||||
// selected-items render, byte-identical to what it produces today.
|
||||
CHECK(itemExtentPrintsWindow(2.0, 8.0, 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testSubFrameDriftStillPrintsTheSameFrames() {
|
||||
// A time selection snapped a fraction of a sample off the item edge prints the
|
||||
// identical frames, so it must NOT be pushed onto the time-bounded path — that
|
||||
// would swap the render mechanism under a capture that was already exact.
|
||||
const double eighthOfAFrameAt48k = 1.0 / (48000.0 * 8.0);
|
||||
CHECK(itemExtentPrintsWindow(2.0 + eighthOfAFrameAt48k, 8.0 - eighthOfAFrameAt48k,
|
||||
2.0, 8.0, 48000));
|
||||
// A full frame of drift is a real difference and must disqualify.
|
||||
const double oneFrameAt48k = 1.0 / 48000.0;
|
||||
CHECK(!itemExtentPrintsWindow(2.0 + oneFrameAt48k, 8.0, 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testUnknownRateFallsBackToExactEquality() {
|
||||
// With no project rate there is no frame grid to compare on. Exact equality
|
||||
// still recognizes the regression floor...
|
||||
CHECK(itemExtentPrintsWindow(2.0, 8.0, 2.0, 8.0, 0));
|
||||
// ...and anything else takes the time-bounded render, which honors the request
|
||||
// whatever the rate turns out to be.
|
||||
const double eighthOfAFrameAt48k = 1.0 / (48000.0 * 8.0);
|
||||
CHECK(!itemExtentPrintsWindow(2.0 + eighthOfAFrameAt48k, 8.0, 2.0, 8.0, 0));
|
||||
CHECK(!itemExtentPrintsWindow(5.0, 6.0, 0.0, 30.0, 0));
|
||||
}
|
||||
|
||||
static void testMultiItemUnionExtent() {
|
||||
// Two items spanning 1..4 and 6..9 present a 1..9 union extent to the render.
|
||||
// A selection over the whole union is expressible; one over only the first
|
||||
// item's half is not.
|
||||
CHECK(itemExtentPrintsWindow(1.0, 9.0, 1.0, 9.0, 48000));
|
||||
CHECK(!itemExtentPrintsWindow(1.0, 4.0, 1.0, 9.0, 48000));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFrameCountIsExactNotRounded();
|
||||
testFrameCountIsADifferenceOfIndicesNotADuration();
|
||||
testFrameCountRefusesEmptyInvertedAndUnknownRate();
|
||||
testRangeInsideItemCannotBeExpressed();
|
||||
testRangeWiderThanItemCannotBeExpressedEither();
|
||||
testEachEdgeAloneDisqualifies();
|
||||
testExtentEqualToWindowIsExpressible();
|
||||
testSubFrameDriftStillPrintsTheSameFrames();
|
||||
testUnknownRateFallsBackToExactEquality();
|
||||
testMultiItemUnionExtent();
|
||||
|
||||
if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; }
|
||||
std::printf("render_window: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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}));
|
||||
}
|
||||
|
||||
static void testSiblingFolderAfterParentClosesIsNotIncluded() {
|
||||
// 0: folder parent, 1: child, 2: last child (closes it). 3: an unrelated sibling
|
||||
// folder that opens AFTER track 0's folder has already closed, 4: its child, 5:
|
||||
// its last child. A walk that fails to stop at track 0's own close would keep
|
||||
// consuming and wrongly pull the sibling's children in too.
|
||||
const std::vector<int> depths{1, 0, -1, 1, 0, -1};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFlatProjectHasNoChildren();
|
||||
testFolderParentReturnsItsDirectChildren();
|
||||
testGrandchildrenAreExcluded();
|
||||
testMultiLevelCloseEndsTheOuterFolderToo();
|
||||
testNonFolderAndOutOfRangeReturnEmpty();
|
||||
testUnterminatedFolderSwallowsTheRest();
|
||||
testSiblingFolderAfterParentClosesIsNotIncluded();
|
||||
|
||||
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user