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
+46 -12
View File
@@ -689,19 +689,53 @@ stereo file today.
`Sample::channelCount` matching, the same way an offline dead-center capture does; a
true-stereo bake is byte-identical to today's output.
## A 0-byte render can pass every gate and land as `Ok` (pre-existing, not a Ψ-W3 regression)
## A 0-byte render can still pass every gate under Auto/Manual tail (narrowed, not closed)
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
(`capture.cpp:489`) passes for a 0-byte file, and the bounds gate (`:507-546`) only fires
when `expectedFrames > 0` — an invalid/empty layout reads `expectedFrames == 0` and skips
the gate rather than refusing. A 0-byte render can therefore reach `stampCaptureSample`
and land as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
passes for a 0-byte file, and the bounds gate used to fire only when `expectedFrames > 0`
— an invalid/empty layout read `expectedFrames == 0` and skipped the gate rather than
refusing, so a 0-byte render reached `stampCaptureSample` and landed as
`CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
**Not introduced by Ψ-W3.** The exists-check and the `expectedFrames > 0` guard both
predate this track; Ψ-W3 only added the mono-collapse failure report that sits downstream
of this hole and was careful not to assert bytes it never verified (see
`reportCollapseFailure` in `capture.cpp`).
**Narrowed.** `shell/capture/render_bounds_gate` now refuses an unmeasurable render
(invalid layout, or a layout declaring no sample rate) instead of skipping it. That
covers `TailMode::None` only — the gate does not judge Auto/Manual, which add frames by
design, so a 0-byte render under either of those still lands as `Ok`. The refusal reuses
`CaptureStatus::BoundsMismatch` rather than minting its own status; the earlier note here
preferred a distinct status, and that preference is unresolved, not withdrawn.
**Intended fix.** After the exists-check, also reject a 0-byte file explicitly (its own
status, not folded into `BoundsMismatch`, since a 0-byte file was never bounds-checked at
all) before anything downstream reads it.
**Intended fix.** Reject a 0-byte / unparseable render right after the exists-check, on
every tail mode, before anything downstream reads it.
## An offline capture can be refused for a short render — root cause open
**Symptom (live, 2026-08-02).** A capture over [0.000000s, 4.067797s) at 48 kHz was
refused: `Render produced 195216 frames but the requested range is 195254`. 38 frames
short — two orders of magnitude outside the gate's one-frame tolerance, so the tolerance
is not what refused it.
**Hypothesis A — the render bounds itself to the media it can see.** REAPER's
selected-items render source (`&32`) derives its bounds from the selected items' own
extents (`src/core/capture/CLAUDE.md` §Gotchas — itself an inference from an observed
defect, not a header fact). If a time-bounded selected-tracks render (`&128`) does the
same thing against content extent, a range running past the end of its material comes up
exactly as short as the material is.
**Hypothesis B — a trailing-silence trim fires anyway.** `TailMode::None` sets
`RENDER_NORMALIZE = &(4<<16)` (disable all postprocessing) and `RENDER_TRIMEND = 0`. If
REAPER trims regardless of that bit, a range whose material decays before its end loses
exactly the decayed frames.
**Not excluded — the gate itself.** `renderHonoredBounds`' one-frame tolerance is
empirical, not proven (`src/core/capture/render_window.h`): a renderer that resolves the
window's two edges by DIFFERENT conventions can sit two frames from `frameCountFor`'s
answer on a correctly-honored render. That cannot account for 38 frames, so it is not
this refusal — but it means a future one- or two-frame refusal may be ours, which is why
the tolerance was not widened on speculation. Widening it is a precision-invariant
decision, not a bug fix.
**How it gets decided.** `docs/VERIFICATION.md` §Capture range and bounds, the three
numbered blocker steps: step 1 separates A's `&32` path from the shared `&128` path (and
says how to tell when it failed to), step 2 asks whether the render is short at all, step
3 reads the retained refused render to place the missing frames. Nothing here should be
"fixed" before that comes back.