capture: render a ranged item capture time-bounded — the selected-items source can't narrow a window, only a full-extent one uses it

sourceModeForScope now takes the item extent vs. the requested window. Full-extent item captures and the batch keep the old path unchanged.
This commit is contained in:
2026-08-01 19:43:06 -04:00
parent 8bf6841f7b
commit 5e3ea6c851
16 changed files with 407 additions and 20 deletions
+41 -9
View File
@@ -203,14 +203,43 @@ 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 Ψ.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.
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);
}
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
@@ -262,8 +291,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 +338,7 @@ int main() {
testParseEmptyAndMalformed();
testRazorUnionBounds();
testScopeSourceModes();
testRangedItemScopeRendersTimeBounded();
testRangeInference();
testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
+120
View File
@@ -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;
}