capture: state the bounds tolerance as empirical, refuse unmeasurable renders, keep refused ones for diagnosis

The one-frame bound is not provable for a per-edge renderer; the test now shows where it breaks. Refused renders move out of the bank instead of being deleted, so the DAW experiment has something to read.
This commit is contained in:
2026-08-02 07:23:30 -04:00
parent a91df760cc
commit 2005f90c66
14 changed files with 339 additions and 126 deletions
+1
View File
@@ -14,6 +14,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_bounds_gate.cpp
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
+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 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.
- `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.
- `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.
+4 -2
View File
@@ -102,8 +102,10 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
const char* renderSourceLabel(SourceMode mode) {
switch (mode) {
case SourceMode::MasterMix: return "master mix";
case SourceMode::TimeSelection: return "master mix (time selection)";
// MasterMix and TimeSelection share this label because they ARE the same
// render — see the header.
case SourceMode::MasterMix:
case SourceMode::TimeSelection: return "master mix";
case SourceMode::SelectedTracks: return "selected tracks via master";
case SourceMode::SelectedItems: return "selected media items";
case SourceMode::RazorArea: return "razor edits";
+6 -1
View File
@@ -100,7 +100,12 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// 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.
// and naming the source is what tells them apart in a bug report. Quoted verbatim
// in docs/VERIFICATION.md, which asks for this exact line back.
//
// MasterMix and TimeSelection deliberately answer the SAME words: they map to the
// same RENDER_SETTINGS value and every capture renders custom-time-bounded, so
// naming them apart would assert a render distinction that does not exist.
const char* renderSourceLabel(SourceMode mode);
// --- Capture scope: the FX-scope invariant ------------------------------------
+12 -12
View File
@@ -13,21 +13,21 @@ namespace reasampler::capture {
// Returns 0 for a non-positive rate or an empty/inverted window.
//
// The offline backend compares this against the rendered file's own frame count, so
// exact-bounds failures surface as a refused capture rather than a wrong file. That
// REAPER resolves the two edges the same way is UNVERIFIED — a DAW pass decides
// whether the equality is exact or off by a frame.
// exact-bounds failures surface as a refused capture rather than a wrong file.
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.
// answer for the same window. Tolerates a one-frame difference, and exactly one.
//
// That bound is EMPIRICAL. It is provable only for renderer models that derive the
// count from the window's LENGTH (floor/ceil/round of (end-start)*rate) or resolve
// both edges by the SAME convention; a renderer that resolves the start edge and the
// end edge by DIFFERENT conventions can legitimately sit TWO frames from this answer
// (tests/test_render_window.cpp pins both facts). Which model REAPER uses is
// unverified, so a refusal one or two frames wide may be this gate's fault rather than
// the render's — the open DAW question in docs/VERIFICATION.md §Capture range and
// bounds. Widening past one frame retires the exact-bounds invariant rather than
// relaxing it, and is not a fix to reach for before that question is answered.
bool renderHonoredBounds(long long expectedFrames, long long actualFrames);
// True when a render bounded by the selected items' own extent
+1
View File
@@ -52,6 +52,7 @@ detail not covered there:
## Modules
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never sees it either way.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
+14 -51
View File
@@ -22,7 +22,6 @@
#include "shell/capture/capture.h"
#include <atomic>
#include <cmath>
#include <cstdint>
#include <ctime>
#include <filesystem>
@@ -35,7 +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" // frameCountFor / renderHonoredBounds — the exact-bounds gate
#include "shell/capture/render_bounds_gate.h" // the exact-bounds verdict on the landed render
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -498,60 +497,24 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result;
}
// Exact bounds, made structural: with no tail requested the file must contain
// 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,
// Exact bounds, made structural: render_bounds_gate judges the landed file against
// the requested window and owns what becomes of a render that fails.
// (On TailMode::None the landed file is read three times on this path — that 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
// once-per-capture cost on an already-warm file, judged acceptable.) A
// bounded/header-only read is not a clean substitute: parseWavLayout only marks the
// data chunk valid when the buffer holds the chunk's FULL declared body
// (bodyInBounds), so a truncated read would read as invalid here on every real
// capture, not just malformed ones.
if (request.tailMode == TailMode::None) {
const WavLayout layout =
parseWavLayout(util::readFileBytes(expectedPath));
const long long expectedFrames = layout.valid
? frameCountFor(request.startSeconds, request.endSeconds,
static_cast<int>(layout.sampleRate))
: 0;
const long long actualFrames = static_cast<long long>(layout.frameCount());
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. "
"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 *
layout.sampleRate)) +
", " +
std::to_string(std::llround(request.endSeconds *
layout.sampleRate)) +
"). Nothing was added to the bank; the render at " +
expectedPath + " was never indexed and has been cleaned up.";
std::error_code ec;
std::filesystem::remove(expectedPath, ec);
return result;
}
// fires; Auto/Manual are not gated, so they read it twice. A once-per-capture cost
// on an already-warm file, judged acceptable.)
const BoundsVerdict bounds =
checkRenderedBounds(expectedPath, projectDir, request);
if (bounds.refused) {
result.status = CaptureStatus::BoundsMismatch;
result.message = bounds.message;
return result;
}
// Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures
// REAPER's own render against the requested window, so nothing of ours may sit
// between the render and that measurement, and a refusal must delete the
// renderer's file rather than one this step had already rewritten. The collapse
// between the render and that measurement, and the file a refusal retains must be
// the renderer's own rather than one this step had already rewritten. The collapse
// preserves the frame count, so the two are order-independent in outcome — only
// in what each is measuring.
const MonoCollapseOutcome collapseOutcome =
+2 -1
View File
@@ -87,7 +87,8 @@ enum class CaptureStatus {
RenderFailed, // the render action ran but produced no output file
TransportBusy, // realtime backend: transport already playing/recording — refused
MultiTrackSelection, // a selected-tracks render over >1 track — would render N files
BoundsMismatch, // the rendered file's frame count is not the requested window's
BoundsMismatch, // the rendered file's frames are not the requested window's — or
// could not be measured to say (render_bounds_gate)
};
struct CaptureResult {
+95
View File
@@ -0,0 +1,95 @@
// render_bounds_gate.cpp — see the header.
#include "shell/capture/render_bounds_gate.h"
#include <cmath>
#include <filesystem>
#include <system_error>
#include <vector>
#include "core/capture/render_settings.h"
#include "core/capture/render_window.h"
#include "core/capture/wav_codec.h"
#include "core/util/file_bytes.h"
namespace reasampler::capture {
namespace {
// Deliberately a sibling of the bank folder, never inside it: prune enumerates the bank
// directory, and a refused render must be reachable by the person debugging it and by
// nothing else.
constexpr const char* kRefusedSubfolder = "reasampler_refused";
// Moves a refused render out of the bank and returns the sentence naming where it went.
// A failed move leaves the file where the renderer wrote it and says so — never a path
// that does not exist.
std::string retainRefusedRender(const std::string& renderedPath,
const std::string& projectDir) {
namespace fs = std::filesystem;
std::error_code ec;
const std::string dir = projectDir + "/" + kRefusedSubfolder;
fs::create_directories(dir, ec);
if (!ec) {
const std::string dest =
dir + "/" + fs::path(renderedPath).filename().string();
fs::rename(renderedPath, dest, ec);
if (!ec)
return " Nothing was added to the bank. The refused render was KEPT for "
"diagnosis at " + dest + " -- outside the bank, indexed by nothing; "
"delete it when done.";
}
return " Nothing was added to the bank. The refused render was kept for diagnosis "
"but could not be moved out of the bank folder; it is still at " +
renderedPath + ", indexed by nothing. Delete it when done.";
}
} // namespace
BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
const std::string& projectDir,
const CaptureRequest& request) {
BoundsVerdict v;
if (request.tailMode != TailMode::None) return v;
// Whole-file read, not a bounded/header-only one: parseWavLayout marks the data
// chunk valid only when the buffer holds its FULL declared body, so a truncated
// read would read as invalid on every real capture — and invalid refuses here.
const WavLayout layout = parseWavLayout(util::readFileBytes(renderedPath));
// Why the refusal names the render source at all: render_settings.h's renderSourceLabel.
const std::string source =
std::string(" Render source: ") + renderSourceLabel(request.sourceMode) + ".";
// An unmeasurable render used to SKIP this gate and land in the bank with an
// unknown channel count. It is a refusal now: exact bounds cannot be asserted over
// a file whose frames were never counted.
if (!layout.valid || layout.sampleRate == 0) {
v.refused = true;
v.message = "Render at " + renderedPath + " could not be measured -- its WAV "
"header did not parse, or declared no sample rate -- so the frames "
"it holds were never checked against the requested range." + source +
retainRefusedRender(renderedPath, projectDir);
return v;
}
const int rate = static_cast<int>(layout.sampleRate);
const long long expectedFrames =
frameCountFor(request.startSeconds, request.endSeconds, rate);
const long long actualFrames = static_cast<long long>(layout.frameCount());
if (renderHonoredBounds(expectedFrames, actualFrames)) return v;
v.refused = true;
v.message = "Render produced " + std::to_string(actualFrames) +
" frames but the requested range is " + std::to_string(expectedFrames) +
" at " + std::to_string(rate) +
" Hz -- the render did not honor the requested bounds." + source +
" Requested [" + std::to_string(request.startSeconds) + "s, " +
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);
return v;
}
} // namespace reasampler::capture
+30
View File
@@ -0,0 +1,30 @@
#pragma once
// render_bounds_gate — the exact-bounds verdict on a landed offline render, and what
// happens to a render that fails it. Split out of capture.cpp on the responsibility
// seam: that TU drives the render, this one judges the file it produced.
// No REAPER types — pure core plus the filesystem.
#include <string>
#include "shell/capture/capture.h"
namespace reasampler::capture {
// A refused render is MOVED out of the bank, not deleted: while the root cause of a
// short render is open (docs/TODO.md), the frames it did print are the evidence — and
// nothing may index a file the bank never accepted.
struct BoundsVerdict {
bool refused = false;
std::string message; // console text; meaningful only when refused
};
// Judges `renderedPath` against `request`'s window. Refuses on two counts: the file's
// frame count is not the window's (render_window::renderHonoredBounds owns the
// tolerance and its limits), or the file cannot be measured at all — an unmeasured
// render is not a verified one. TailMode::Auto/Manual add frames by design and are
// never judged here. `projectDir` is where a refused render is parked.
BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
const std::string& projectDir,
const CaptureRequest& request);
} // namespace reasampler::capture