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
@@ -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