capture: name the render source in the exact-bounds refusal, and put its one-frame tolerance under test

The tolerance is unchanged and now derived, not assumed: frameCountFor lands in {floor(L), ceil(L)}, so a non-frame-aligned window can never miss by more than a frame. Naming the source is what tells a self-bounding render from a short one.
This commit is contained in:
2026-08-02 06:37:40 -04:00
parent 6e937b9c61
commit a91df760cc
9 changed files with 157 additions and 20 deletions
+2 -2
View File
@@ -51,8 +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.
- `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`), 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 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.
- `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 and the derivation behind it), 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.
+12
View File
@@ -100,6 +100,18 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
return c;
}
const char* renderSourceLabel(SourceMode mode) {
switch (mode) {
case SourceMode::MasterMix: return "master mix";
case SourceMode::TimeSelection: return "master mix (time selection)";
case SourceMode::SelectedTracks: return "selected tracks via master";
case SourceMode::SelectedItems: return "selected media items";
case SourceMode::RazorArea: return "razor edits";
case SourceMode::Realtime: return "realtime record";
}
return "unknown"; // unreachable for a valid enum; never claim a source
}
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
switch (scope) {
case CaptureScope::Item:
+7
View File
@@ -96,6 +96,13 @@ struct RenderSettingsChoice {
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// The render source a mode drives, in words. Exists for the offline backend's
// bounds refusal: the two ways a render can miss its window — a source that
// derives its own bounds (selected items, razor edits) versus a time-bounded
// render that came up short — are indistinguishable from a frame count alone,
// and naming the source is what tells them apart in a bug report.
const char* renderSourceLabel(SourceMode mode);
// --- Capture scope: the FX-scope invariant ------------------------------------
//
// See src/core/capture/CLAUDE.md for the scope contract. There is NO master
+7
View File
@@ -24,6 +24,13 @@ long long frameCountFor(double startSeconds, double endSeconds, int sampleRate)
return frames > 0 ? frames : 0;
}
bool renderHonoredBounds(long long expectedFrames, long long actualFrames) {
const long long delta = actualFrames > expectedFrames
? actualFrames - expectedFrames
: expectedFrames - actualFrames;
return delta <= 1;
}
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate) {
+12
View File
@@ -18,6 +18,18 @@ namespace reasampler::capture {
// whether the equality is exact or off by a frame.
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
// True when a landed render's frame count is consistent with `frameCountFor`'s
// answer for the same window. Tolerates a one-frame difference, and exactly one:
// frameCountFor rounds EACH edge, so it sits within a frame of the window's
// real-valued length (end-start)*rate — and a renderer that floors, ceils or
// rounds that same length sits within a frame of it too, so two integers derived
// that way can never be more than one apart. A window whose edges do not land on
// frame boundaries therefore cannot produce a larger difference; anything larger
// is a render that printed something other than the window asked for, whatever
// the alignment. Widening this past one frame retires the exact-bounds invariant
// rather than relaxing it — do not.
bool renderHonoredBounds(long long expectedFrames, long long actualFrames);
// 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
+13 -16
View File
@@ -35,7 +35,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" // frameCountFor — the exact-bounds number
#include "core/capture/render_window.h" // frameCountFor / renderHonoredBounds — the exact-bounds gate
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -499,9 +499,10 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
}
// Exact bounds, made structural: with no tail requested the file must contain
// (within a tolerance, see below) the requested window's frames, so a source
// mode that silently widened the render fails loudly here instead of landing as
// a successful capture. Auto and Manual add frames by design and are skipped.
// the requested window's frames (renderHonoredBounds owns the tolerance and the
// reasoning behind it), so a render that printed something other than the window
// fails loudly here instead of landing as a successful capture. Auto and Manual
// add frames by design and are skipped.
// (On TailMode::None the landed file is read three times on this path — this gate,
// the mono collapse, and stampCaptureSample — plus one rewrite when the collapse
// fires; Auto/Manual skip this gate entirely, so they read it twice. A
@@ -518,24 +519,20 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
static_cast<int>(layout.sampleRate))
: 0;
const long long actualFrames = static_cast<long long>(layout.frameCount());
// frameCountFor is a difference of frame indices, not a rounded duration
// (see render_window.h) — REAPER's own edge-rounding can legitimately land
// one frame off that, so the gate tolerates +/-1 rather than exact equality.
// The defect this refuses is a whole-item widening (seconds of extra audio,
// thousands of frames), which a 1-frame tolerance still catches with
// certainty. Tightening to exact equality needs a DAW pass confirming REAPER
// resolves the window's two edges to frame indices the same way this does.
const long long frameDelta = actualFrames > expectedFrames
? actualFrames - expectedFrames
: expectedFrames - actualFrames;
if (expectedFrames > 0 && frameDelta > 1) {
if (expectedFrames > 0 &&
!renderHonoredBounds(expectedFrames, actualFrames)) {
result.status = CaptureStatus::BoundsMismatch;
// Naming the render source is load-bearing, not decoration: a source that
// derives its own bounds and a time-bounded render that came up short
// produce the same frame count, and only one of them is a routing defect.
result.message = "Render produced " + std::to_string(actualFrames) +
" frames but the requested range is " +
std::to_string(expectedFrames) + " at " +
std::to_string(layout.sampleRate) +
" Hz -- the render did not honor the requested bounds. "
"Requested [" + std::to_string(request.startSeconds) +
"Render source: " +
renderSourceLabel(request.sourceMode) +
". Requested [" + std::to_string(request.startSeconds) +
"s, " + std::to_string(request.endSeconds) +
"s) -> frame indices [" +
std::to_string(std::llround(request.startSeconds *