Merge render-bounds diagnostics: prove the window at the boundary it crosses, on every tail mode

This commit is contained in:
2026-08-02 14:47:19 -04:00
7 changed files with 295 additions and 6 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ Detail specific to these pure modules:
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `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`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
- `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 render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), 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 render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the two short-render diagnostics: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched, quoted by the refusal as a count coincidence and nothing more) and `describeBoundsDrift` (the sentence the offline backend prints when `RENDER_STARTPOS`/`RENDER_ENDPOS` do not read back as they were written).
- `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.
+46
View File
@@ -3,6 +3,7 @@
#include "core/capture/render_window.h"
#include <cmath>
#include <cstdio>
namespace reasampler::capture {
@@ -15,6 +16,22 @@ long long frameIndexAt(double seconds, int sampleRate) {
return std::llround(seconds * static_cast<double>(sampleRate));
}
// See the header for why whole milliseconds get a tolerance and why it is this small.
double floorToMilliseconds(double seconds) {
const double ms = seconds * 1000.0;
const double nearest = std::nearbyint(ms);
if (std::fabs(ms - nearest) < 1e-6) return nearest / 1000.0;
return std::floor(ms) / 1000.0;
}
// Full round-trip precision: a drift report whose two numbers print identically
// would be evidence of nothing.
std::string exactly(double seconds) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.17g", seconds);
return buf;
}
} // namespace
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
@@ -41,4 +58,33 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
}
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate) {
return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate);
}
std::string describeBoundsDrift(double reqStart, double reqEnd,
double storedStart, double storedEnd,
int sampleRate) {
// Bit equality, deliberately: the caller wrote these exact doubles and read them
// straight back, so anything but the same bits is a value REAPER changed.
if (storedStart == reqStart && storedEnd == reqEnd) return {};
// Says only that the two differ, not why -- a legitimate clamp (negative start,
// end past project end) reads back differently for the same reason a precision
// defect would, and this sentence cannot tell those apart.
std::string s = "REAPER read back different render bounds than it was handed -- "
"asked for [" +
exactly(reqStart) + "s, " + exactly(reqEnd) + "s), read back [" +
exactly(storedStart) + "s, " + exactly(storedEnd) + "s).";
if (sampleRate > 0) {
s += " The stored window is " +
std::to_string(frameCountFor(storedStart, storedEnd, sampleRate)) +
" frames against the " +
std::to_string(frameCountFor(reqStart, reqEnd, sampleRate)) +
" the request asks for, at " + std::to_string(sampleRate) + " Hz.";
}
return s;
}
} // namespace reasampler::capture
+31 -2
View File
@@ -1,9 +1,14 @@
#pragma once
// render_window — pure frame arithmetic for a capture's requested window: the
// frame count a project-time range occupies, and whether a render whose bounds
// come from the selected items' own extent already prints that window.
// frame count a project-time range occupies, whether a render whose bounds come
// from the selected items' own extent already prints that window, and the two
// diagnostics that bound a short render without locating it: whether the stored
// RENDER_* bounds round-tripped, and whether the shortfall matches a millisecond-
// floor coincidence.
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
#include <string>
namespace reasampler::capture {
// Frames the [startSeconds, endSeconds) window occupies at `sampleRate`. Both
@@ -42,4 +47,28 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate);
// --- Diagnostics: where a short render lost its frames ------------------------
// The frames this window would hold if its END were resolved on a whole-millisecond
// grid, floored, instead of exactly. Two live short renders (48 kHz, TailMode::None)
// matched this count to the frame, which is the entire reason it exists.
//
// A COINCIDENCE OF COUNTS, not a claim about how anything resolved the end: nothing
// renders from this number and no capture path asks for it. Whole-millisecond values
// are recognized within a nanosecond, because a decimal millisecond is not always one
// in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a
// millisecond from a window already on the grid. A nanosecond is far under one frame
// at any rate we render, so a real sub-millisecond remainder still floors.
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate);
// The sentence a capture prints when the render bounds it handed REAPER did not read
// back unchanged — the requested window, what came back, and both frame counts at
// `sampleRate` (omitted when the rate is unknown). EMPTY when both edges read back
// bit-identical, which is the only answer proving the request crossed into REAPER
// intact; a caller prints this only when it is non-empty.
std::string describeBoundsDrift(double reqStart, double reqEnd,
double storedStart, double storedEnd,
int sampleRate);
} // namespace reasampler::capture
+37
View File
@@ -34,6 +34,7 @@
#include "core/capture/wav_codec.h" // hashWavContent / collapseToMono — the one WAV/RIFF owner
#include "core/util/file_bytes.h"
#include "core/capture/render_settings.h"
#include "core/capture/render_window.h" // describeBoundsDrift — the read-back's verdict
#include "shell/capture/render_bounds_gate.h" // the exact-bounds verdict on the landed render
#define REAPERAPI_MINIMAL
@@ -424,6 +425,12 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
// The requested window crosses out of this process HERE and nowhere else, so the
// read-back is the only evidence available on this side of that boundary for
// whether REAPER kept it. Reported below, once the project rate is known.
const double storedStart = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false);
const double storedEnd = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false);
// TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact
// bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no
@@ -453,6 +460,22 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_SRATE",
static_cast<double>(effectiveSampleRate), true);
}
// Silent unless a bound came back changed. Fires on EVERY tail mode on purpose:
// only None is judged against its window after the render, so this is the sole
// signal an Auto/Manual capture was shortened before it ever started. Named by
// checkpoint so a DAW observation is self-locating: three reads bracket the two
// places REAPER could quantize — the store, and the render itself.
auto reportDrift = [&](const char* checkpoint, double atStart, double atEnd) {
const std::string drift =
describeBoundsDrift(request.startSeconds, request.endSeconds,
atStart, atEnd, effectiveSampleRate);
if (!drift.empty())
ShowConsoleMsg(("ReaSampler capture (" + std::string(checkpoint) + "): " +
drift + "\n").c_str());
};
reportDrift("at store", storedStart, storedEnd);
GetSetProjectInfo(proj, "RENDER_CHANNELS",
static_cast<double>(request.channelCount), true);
@@ -485,8 +508,22 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
}
setProjString(proj, "RENDER_FORMAT", fmtBase64);
// Read again right here: a mismatch against the store-time read-back above
// means something between the two writes and this line moved the bounds,
// before the render ever ran.
reportDrift("before render",
GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false),
GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false));
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
// And once more here, while the guard above is still live and before it restores
// anything: only the gap between this read and the one immediately above can be
// the render itself.
reportDrift("after render",
GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false),
GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false));
// Main_OnCommand returns void, so a failed render is silent — stat the
// expected output path to detect it.
const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName;
@@ -369,6 +369,11 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
// recordWindowEnd extends past the range end for a tail mode so the
// transport captures the decay; cursor + time selection are restored by restore().
// `[verify — DAW]` whether rs/re come back changed on this isSet=true call: the SDK
// header names both `double*` but documents no read-back semantics for either
// direction, and nothing here reads rs/re again after the call to notice. Lower
// stakes than the offline RENDER_* store: completion is driven by the play cursor
// reaching the range end (tick(), below), not by re-reading this pair.
double rs = request.startSeconds, re = st->recordWindowEnd_;
GetSet_LoopTimeRange(true, false, &rs, &re, false);
SetEditCurPos(request.startSeconds, false, false);
+17 -1
View File
@@ -99,6 +99,22 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
const long long actualFrames = static_cast<long long>(layout.frameCount());
if (renderHonoredBounds(expectedFrames, actualFrames)) return v;
// Says whether this shortfall has the one shape two live short renders already
// matched to the frame: the END alone floored to the millisecond. Checked against
// the END only -- a refusal whose START is also off-grid and independently floored
// would not match this shape, and this note's silence on that refusal is this
// check not covering it, not the coincidence breaking. Excludes 0, which every
// sub-millisecond window (a legitimate day-one capture) also floors to, and which
// would otherwise match a render that produced nothing. A count coincidence only —
// it does not establish how the render resolved anything.
const long long msFlooredEnd =
msFlooredEndFrameCount(request.startSeconds, request.endSeconds, rate);
const std::string msNote =
(msFlooredEnd > 0 && actualFrames == msFlooredEnd)
? " Those are exactly the frames this window holds with its end floored to"
" the millisecond -- a match on the count, not a measured cause."
: std::string();
v.refused = true;
v.message = "Render produced " + std::to_string(actualFrames) +
" frames but the requested range is " + std::to_string(expectedFrames) +
@@ -108,7 +124,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
std::to_string(request.endSeconds) + "s) -> frame indices [" +
std::to_string(std::llround(request.startSeconds * rate)) + ", " +
std::to_string(std::llround(request.endSeconds * rate)) + ")." +
retainRefusedRender(renderedPath, projectDir);
msNote + retainRefusedRender(renderedPath, projectDir);
return v;
}
+158 -2
View File
@@ -1,13 +1,14 @@
// 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), the verdict the offline backend refuses a capture on, and the predicate
// rate), the verdict the offline backend refuses a capture on, the predicate
// that decides whether REAPER's selected-items render source can express a
// requested window at all.
// requested window at all, and the two short-render diagnostics.
#include "../src/core/capture/render_window.h"
#include <cmath>
#include <cstdio>
#include <string>
using namespace reasampler::capture;
@@ -15,6 +16,10 @@ 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 contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
// --- frameCountFor: the bounds equality, stated as a number ------------------
static void testFrameCountIsExactNotRounded() {
@@ -226,6 +231,146 @@ static void testMultiItemUnionExtent() {
CHECK(!itemExtentPrintsWindow(1.0, 4.0, 1.0, 9.0, 48000));
}
// --- msFlooredEndFrameCount: the shape both live short renders had ------------
static void testMillisecondFlooredEndReproducesBothShortRenders() {
// Both DAW observations, as arithmetic. 48 kHz, TailMode::None, start at 0: the
// requested window's count, and the count its end floored to the millisecond
// holds — which is what each render actually printed.
CHECK(frameCountFor(0.0, 4.067797, 48000) == 195254);
CHECK(msFlooredEndFrameCount(0.0, 4.067797, 48000) == 195216);
CHECK(frameCountFor(0.0, 4.067797, 48000) -
msFlooredEndFrameCount(0.0, 4.067797, 48000) == 38);
CHECK(frameCountFor(0.0, 1.655172, 48000) == 79448);
CHECK(msFlooredEndFrameCount(0.0, 1.655172, 48000) == 79440);
CHECK(frameCountFor(0.0, 1.655172, 48000) -
msFlooredEndFrameCount(0.0, 1.655172, 48000) == 8);
}
static void testTheSixDecimalDisplayDidNotCreateTheEffect() {
// Both reported ends were printed to six decimals by the refusal. Each is one 4/4
// bar — at 59 BPM and at 145 BPM — so the full-precision doubles behind them are
// 240/59 and 240/145. Same counts either way: the display rounding is not what
// produces the shortfall.
CHECK(frameCountFor(0.0, 240.0 / 59.0, 48000) == 195254);
CHECK(msFlooredEndFrameCount(0.0, 240.0 / 59.0, 48000) == 195216);
CHECK(frameCountFor(0.0, 240.0 / 145.0, 48000) == 79448);
CHECK(msFlooredEndFrameCount(0.0, 240.0 / 145.0, 48000) == 79440);
}
static void testWindowAlreadyOnTheMillisecondGridLosesNothing() {
// The "sometimes it works" case: a bar at 120 BPM is exactly 2 s.
CHECK(msFlooredEndFrameCount(0.0, 2.0, 48000) == frameCountFor(0.0, 2.0, 48000));
// The binary-representation trap a bare floor would fall into. The premise, not an
// outcome: 1.007 s is a whole millisecond that really does land BELOW 1007 ms in
// double, so flooring it without a tolerance drops a millisecond from a window
// already on the grid.
CHECK(1.007 * 1000.0 < 1007.0);
CHECK(frameCountFor(0.0, 1.007, 48000) == 48336);
CHECK(msFlooredEndFrameCount(0.0, 1.007, 48000) == 48336);
// Same end reached from a non-zero start, so nothing here rests on the window
// beginning at 0.
CHECK(msFlooredEndFrameCount(0.5, 1.007, 48000) ==
frameCountFor(0.5, 1.007, 48000));
}
static void testOneFrameOfRemainderStillFloors() {
// The whole-millisecond tolerance must sit far below a frame, or it would swallow
// the very remainder this diagnostic exists to find. A remainder JUST BELOW a
// millisecond boundary is the discriminating case: one frame short of 1.0 s is
// 999.979166 ms, only ~0.0208 ms off the next whole millisecond. The shipped
// nanosecond tolerance still floors it down; a tolerance any wider than ~0.021 ms
// would snap it up to the millisecond instead and this test would then see 48000,
// not 47952 — which is what would fail if the tolerance regressed to something
// that wide.
const double oneFrame = 1.0 / 48000.0;
CHECK(frameCountFor(0.0, 1.0 - oneFrame, 48000) == 47999);
CHECK(msFlooredEndFrameCount(0.0, 1.0 - oneFrame, 48000) == 47952);
}
static void testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames() {
// 44.1 kHz: a millisecond is 44.1 frames, so a floored end cannot be described as
// dropping a whole number of frames — the count still resolves exactly.
CHECK(frameCountFor(0.0, 0.0105, 44100) == 463);
CHECK(msFlooredEndFrameCount(0.0, 0.0105, 44100) == 441);
// And a window that IS on the millisecond grid there is untouched, even though its
// edge is not on a frame boundary.
CHECK(frameCountFor(0.0, 0.010, 44100) == 441);
CHECK(msFlooredEndFrameCount(0.0, 0.010, 44100) == 441);
}
static void testASubMillisecondStartWouldNotHideItself() {
// Both observations started at 0.000000s, the one value that hides a start-side
// truncation. A window whose START carries a sub-millisecond remainder counts from
// that exact start...
const double start = 1.0001724, end = 2.0001724;
CHECK(frameCountFor(start, end, 48000) == 48000);
// ...so a start floored to the millisecond would print a DIFFERENT count — 8 frames
// more, the same remainder the second observation lost off its end. A start-side
// truncation is therefore visible to the same frame-count gate, not silent.
CHECK(frameCountFor(1.000, end, 48000) == 48008);
CHECK(!renderHonoredBounds(frameCountFor(start, end, 48000),
frameCountFor(1.000, end, 48000)));
}
// --- describeBoundsDrift: the read-back's verdict ------------------------------
static void testBoundsThatReadBackUnchangedDescribeNothing() {
// The answer that proves the request crossed into REAPER intact — including for a
// window whose end is nowhere near a millisecond boundary.
CHECK(describeBoundsDrift(0.0, 4.067797, 0.0, 4.067797, 48000).empty());
CHECK(describeBoundsDrift(1.0001724, 2.0001724, 1.0001724, 2.0001724, 48000).empty());
}
static void testADriftedEndNamesBothWindowsAndBothCounts() {
const std::string s =
describeBoundsDrift(0.0, 4.067797, 0.0, 4.067, 48000);
CHECK(!s.empty());
// Both counts as literals from the DAW observation, not re-derived from the same
// functions the sentence was built with.
CHECK(contains(s, "195254")); // what the request asks for
CHECK(contains(s, "195216")); // what the drifted window would hold
CHECK(contains(s, "48000 Hz"));
}
static void testTheReportPrintsEnoughDigitsToShowTheDrift() {
// A report whose two numbers print identically is evidence of nothing. Two ends a
// single ULP apart — far under the sixth decimal a shorter rendering would stop at
// — must still read as two different numbers. Pinned as the actual %.17g literals
// (not the needle the two ends share, "s)", which occurs at every precision and so
// proves nothing): a report that regressed to a shorter format like %.6g would
// print the same six significant digits for both ends, and these two `contains`
// checks would then fail.
const double asked = 4.067797;
const double stored = std::nextafter(asked, 5.0);
char askedBuf[32], storedBuf[32];
std::snprintf(askedBuf, sizeof(askedBuf), "%.17g", asked);
std::snprintf(storedBuf, sizeof(storedBuf), "%.17g", stored);
CHECK(std::string(askedBuf) != std::string(storedBuf));
const std::string s = describeBoundsDrift(0.0, asked, 0.0, stored, 48000);
CHECK(!s.empty());
CHECK(contains(s, askedBuf));
CHECK(contains(s, storedBuf));
}
static void testADriftedStartIsCaughtToo() {
// The edge both observations could not test.
const std::string s = describeBoundsDrift(1.0001724, 2.0, 1.000, 2.0, 48000);
CHECK(!s.empty());
CHECK(contains(s, "1.0001724"));
}
static void testAnUnknownRateStillReportsTheDriftWithoutFrames() {
// A project that never pinned a rate reads 0. The drift is still worth saying; a
// frame count over an unknown rate is not.
const std::string s = describeBoundsDrift(0.0, 4.067797, 0.0, 4.067, 0);
CHECK(!s.empty());
CHECK(!contains(s, "frames"));
}
int main() {
testFrameCountIsExactNotRounded();
testFrameCountIsADifferenceOfIndicesNotADuration();
@@ -244,6 +389,17 @@ int main() {
testSubFrameDriftStillPrintsTheSameFrames();
testUnknownRateFallsBackToExactEquality();
testMultiItemUnionExtent();
testMillisecondFlooredEndReproducesBothShortRenders();
testTheSixDecimalDisplayDidNotCreateTheEffect();
testWindowAlreadyOnTheMillisecondGridLosesNothing();
testOneFrameOfRemainderStillFloors();
testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames();
testASubMillisecondStartWouldNotHideItself();
testBoundsThatReadBackUnchangedDescribeNothing();
testADriftedEndNamesBothWindowsAndBothCounts();
testTheReportPrintsEnoughDigitsToShowTheDrift();
testADriftedStartIsCaughtToo();
testAnUnknownRateStillReportsTheDriftWithoutFrames();
if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; }
std::printf("render_window: all checks passed\n");