Merge render-bounds channel experiment: hand the window over on a switchable channel, and print a verdict that is proof only when no floored model fits

This commit is contained in:
2026-08-02 16:23:51 -04:00
14 changed files with 887 additions and 81 deletions
+69 -20
View File
@@ -710,37 +710,86 @@ refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own statu
earlier note here preferred a distinct status, and that preference is unresolved, not
withdrawn.
## An offline capture can be refused for a short render — root cause open
## An offline capture can be refused for a short render — the millisecond floor
**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 — 38x the gate's one-frame tolerance, so the tolerance is not what refused it.
**Measured cause (live, 2026-08-02).** Two captures at 48 kHz, `TailMode::None`, matched
their landed frame count to the frame with their window's END floored to the millisecond:
`[0s, 1.6551724137931001s)` printed 79440 of 79448 (the console's own `%.17g` read-back),
and `[0s, ~4.067797s)` — the double nearest the six-decimal value the original refusal
actually printed, `4.0677966101694913`, not itself a captured console value — printed
195216 of 195254. A three-checkpoint read-back on `RENDER_STARTPOS`/`RENDER_ENDPOS` was
silent at store time and immediately before the render, so REAPER writes the floored end
back as a side effect of rendering, not before it. Mechanism, why two
`RENDER_BOUNDSFLAG` channels exist, and the live experiment this observation opened:
`src/core/capture/render_settings.h`'s `RenderBoundsChannel` — the one narrative home;
this entry stays the record of what was actually measured.
**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.
**Disproven by that observation.** The two hypotheses this entry previously carried — that
the render bounds itself to the media it can see, and that a trailing-silence trim fires
despite `RENDER_NORMALIZE = &(4<<16)` — both predict a shortfall tracking CONTENT. This
one tracks the WINDOW: it is the exact ms-floored count, whatever the material does.
Neither is the cause. Do not reinstate either without a fresh observation.
**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.
**Still open — the START edge.** Every observation to date started at `0s`. Floor, ceil
and round all leave `0s` alone (it is exactly representable in binary), so nothing is
known about whether the start floors too, and it is the case that matters most: an end
floor refuses loudly, a start floor would shift content and break the null test silently.
**A premise NOT to build on — an on-grid value is not uniformly safe from a bare floor.**
That claim holds for `0s` only because `0` is exact in binary; it is FALSE in general for
a decimal millisecond grid point: `1.007 * 1000 == 1006.9999999999999` (floors to 1006,
not 1007), and `4.068 * 1000 == 4067.9999999999995` (floors to 4067, not 4068). If a
future fix compensates for a discovered START floor by grid-aligning the extraction — e.g.
slicing a buffer from `floor(start_ms)` — and builds that arithmetic on the false premise,
a `1.007`-class start would resolve a whole millisecond early: frame count right, content
shifted, exactly the silent null-test misalignment this effort exists to catch. Neither
live observation above can detect this hazard (both are off-grid sub-millisecond
remainders, not grid points) — a `1.007`-class grid point must be measured in the DAW
before any extraction logic is built on this premise. `render_window`'s own
`isOnMillisecondGrid`/`floorToMilliseconds` already handle this correctly, but only on OUR
side of the boundary; that tolerance cannot influence how REAPER itself resolves a value we
hand it, which is the open question here, not a closed one.
**Still open — where the floor lives.** Custom time bounds is one of eight
`RENDER_BOUNDSFLAG` modes. Whether the floor sits in that field's own render-time
resolution or downstream in the render engine (where no mode escapes it) cannot be
answered from the SDK header. `RenderBoundsChannel`
(`src/core/capture/render_settings.h`) is the experiment; `docs/VERIFICATION.md`
§Capture range and bounds is the one smoke run that settles it.
**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
these refusals — 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.
## `capture.cpp` is over the ~600-line ceiling — documented, not split mid-experiment
**Context.** The bounds-channel live experiment (`RenderBoundsChannel`, this same
section above) added the time-selection guard/read-back plumbing and the always-on
verdict print to `OfflineRenderBackend::capture`, landing the file at 697 lines against
root `CLAUDE.md`'s ~600-line ceiling. The named seam: the drift/verdict instrumentation
block (`ScopedTimeSelection`/read-back/drift-report/verdict-print, roughly
`capture.cpp:449-655`).
**Deferred, not silent.** ≈60 of the added lines are temporary probe instrumentation
with a known removal date (the experiment closes when `docs/VERIFICATION.md` §Capture
range and bounds comes back), and splitting the file mid-experiment risks moving the
exact code the smoke run is measuring. Split after the experiment closes, onto the seam
named above.
## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated)
Idea considered and dropped: read a captured file's `BWF:TimeReference` tag back as
independent evidence on the START-edge millisecond-floor question above. `WDL/metadata.h`'s
`WriteMetadataPrefPos` only writes it past its `prefpos > 0.0` guard (`:1301`) — that guard
alone is enough to rule the approach out. One nuance worth recording separately: the
millisecond quantization at `:1382-1383` (`AddMexMetadata`'s `ParseUInt64(val)/1000.0`)
belongs to the MEX caller, not proven to be `WriteMetadataPrefPos`'s own behavior or the
renderer's direct call into it — so even without the guard, a floored bext tag would show
that MEX quantizes, not that the render engine does.
## Split `render_bounds_gate` on the verdict/message vs. filesystem seam
+5 -4
View File
@@ -26,10 +26,11 @@ Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, i
- [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`PLAN.md:2136`)
- [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`PLAN.md:2137`)
- [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md:829`)
- [ ] **Open blocker — root cause unknown; the three steps below are the experiment** (both live hypotheses and what is NOT yet excluded: `docs/TODO.md` §An offline capture can be refused for a short render). A live capture over [0.000000s, 4.067797s) was refused 38 frames short (195216 of 195254 at 48 kHz). Set View → time unit to Samples first
- [ ] **Step 1 — does the shortfall follow the render source?** This only tests anything if item scope actually reaches REAPER's selected-items render, and it does that ONLY when the selected items' extent already equals the requested window (`itemExtentPrintsWindow`, `src/core/capture/render_window.h`); otherwise item scope re-sources through the items' own tracks — the same source track scope uses, so the two runs would test one thing twice. So: snap the time selection to the item's exact start and end, run **item** scope, then **track** scope over the identical range. Report both `Render source:` lines and both frame counts. **If both lines read `selected tracks via master`, the item path was NOT exercised** — the extents did not match; re-snap and repeat before concluding anything. **A landing `&32` run here is not evidence `&32` honours custom bounds** — at a window snapped to the item's own extent, a render that honours the window and one that bounds itself to media content print IDENTICAL frames, so this step cannot tell those two apart; it only tells you which render source is in play. **If neither run refuses at this snapped range, the blocker did not reproduce here** — this range does not recreate the original refusal, which ran past the end of its media; move to step 2, which does
- [ ] **Step 2 — full-length or short?** Extend the same range ~1 s past the end of all media, **track** scope. Landing with the full range (no refusal, the card reads the extended length) rules out BOTH a trailing-silence trim and a content-extent bound at once — but it also means there is no refused render for step 3 to read; re-run the ORIGINAL refusing range ([0.000000s, 4.067797s), track scope) to produce one before continuing. A short render does NOT tell the two hypotheses apart: a trim firing despite `RENDER_NORMALIZE &(4<<16)` and a render bounding itself to content extent produce the same count — and that render IS the one step 3 reads. Report which happened, then run step 3
- [ ] **Step 3 — where are the missing frames?** Reads the short render from step 2 (or, if step 2 landed, the fresh refused render from re-running the original range per step 2's note) — not anything step 1 may have left behind, since a correctly-snapped step 1 should not have refused at all. A refused render is kept deliberately, not deleted: it is moved to `<project folder>/reasampler_refused/`. **Follow the path in the refusal line, not this sentence** — if the move itself failed the file stays in the bank folder, unindexed, and the line says which happened. Filenames carry a timestamp/counter but no scope marker, so if more than one file has landed in `reasampler_refused/` by now, the one from step 2 is the most recently written one — or empty the folder before running step 2 so there is only one candidate. Insert it against the source over the same range and report whether the head aligns. Frames missing from the TAIL with an aligned head fits either a tail trim or a content-extent bound; a head offset fits neither and is a start-position defect. Also report whether the media under the range ends before the range does. Delete `reasampler_refused/` when done — nothing in the bank references it
- [ ] **The millisecond floor — what to expect.** A custom-bounds render is known to floor its window's END to the millisecond and write the floored value back over `RENDER_ENDPOS`. Mechanism, why two `RENDER_BOUNDSFLAG` channels exist, and the two live observations behind this: `src/core/capture/render_settings.h`'s `RenderBoundsChannel` and `docs/TODO.md` "An offline capture can be refused...". **Both live observations started at `0s`, on the grid, so nothing is known about the START edge**
- [ ] **The one experiment — does another bounds mode escape the floor?** This build renders on the TIME SELECTION channel (`RENDER_BOUNDSFLAG=2`, window handed over via `GetSet_LoopTimeRange`) instead of custom time bounds. On every return past the point a bounds channel is chosen (the format/mode/empty-range/no-project refusals answer earlier and print nothing), one line beginning `ReaSampler capture -- bounds channel:` prints — including the two paths that answer before any bounds are judged (unsupported format, no output file), which print `NOT JUDGED` rather than staying silent. Read the verdict: **the floor's signature is ONLY a sentence naming "floored to the millisecond"** — a bare SHORT with no such sentence means the shortfall's cause is unestablished, and LONG can never be the floor's signature (a floor only removes frames, never adds them). **(WITHIN TOLERANCE)** on a SHORT/LONG is the gate's ordinary ±1-frame edge-convention slack (`render_window.h`), not the floor — don't read it as either result, and it can mask a floor: a window whose start and end sit in the same millisecond bucket makes the floored count equal the exact one, so a real one-frame floor there reads as a bare SHORT (WITHIN TOLERANCE) with no floor sentence at all. **EXACT is the fix only when the line carries no further caveat.** The verdict checks the observed count against every millisecond-floored model of the window (start floored alone, end floored alone, both together) and names any that reproduce it — grid membership on either edge is a proxy for that collision, not the test itself, so the caveat can fire even when NEITHER edge sits on the millisecond grid (sub-millisecond remainders on the two edges can cancel under a full floor — a dragged, fixed-length time selection is the reproducible case). Re-run with a window the caveat doesn't name before trusting EXACT. **NOT JUDGED naming a bounds channel** means that capture answered nothing (tail mode was not None, the render was empty, the render never even reached a bounds check, or the window rounds to 0 frames at this rate) OR the render source itself is INFERRED (not SDK-confirmed) to derive its own bounds and never consult the channel — selected-items captures always read this way; razor edits never do today, because no offline capture path assigns `SourceMode::RazorArea` (razor is a range source resolved through track/item scope, not a render source of its own) — so pick a window narrower than the selected item(s) to route through the time-bounded source instead
- [ ] **Same run, the START edge.** The verdict line also says whether the run tested the start. Capture a range whose start is NOT a whole millisecond (set View → time unit to Samples, then nudge the selection start off the grid) so the line reads `The START edge IS tested here`. Report that line verbatim — it is the only evidence available for whether the start floors too, and a start floor is the case that would break the null test silently rather than loudly
- [ ] **Auto and Manual tail.** Repeat the off-grid-start/off-grid-end capture once with the panel tail toggle at **Auto** and once at **Manual**. Neither is judged against a frame count, so the evidence is the `after render` drift lines: report whether either channel's bounds read back changed. `[verify — DAW]` A tail is assumed to render PAST the window end — the SDK header (`:3048`) confirms only that `RENDER_TAILMS` is a length in ms, not that it extends past the end. If that assumption is wrong, an end floor could be costing Auto/Manual real content with no detector (`checkRenderedBounds` returns immediately for `tailMode != None`) — so also report by ear/measurement whether either tail capture comes up short against the source, not only whether the bounds fields drifted
- [ ] Whichever way the experiment lands, the refused render is still kept for diagnosis at `<project folder>/reasampler_refused/` (the refusal line names the path; a failed move leaves it unindexed in the bank folder and says so). Delete the folder when done — nothing in the bank references it
## Names and channels