From fa69b6c5473e923f3b954c36db433ea2b33b65ee Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 17:22:20 -0400 Subject: [PATCH] docs: record the millisecond floor as located and closed, and split what stayed open The retired custom-bounds premise is corrected wherever it was encoded: the tail bit is the time selection's, not custom bounds'. --- docs/COMPLETED.md | 39 ++++++++++++ docs/TODO.md | 112 ++++++++++++++++++++--------------- docs/product/capture-tail.md | 42 ++++++++----- 3 files changed, 128 insertions(+), 65 deletions(-) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index f7f86bc..b3af65a 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -942,3 +942,42 @@ own output, the three folder cases, collapsed-mono placement and summing, both m transitions waited out past a panel timer tick, undo, name/colour clone, and `GetProjectPathEx` against a non-default recording path — none of it is unit-testable and none has been run. + +### The offline-render millisecond floor — located and closed (ad-hoc) + +Closes the `docs/TODO.md` entry of the same name. REAPER's offline render was +intermittently refusing an otherwise-valid capture whenever the requested window's end +carried a sub-millisecond remainder — breaking root `CLAUDE.md`'s "exact bounds — no +rounding of the requested range" precision invariant. + +**Located, not inferred: the floor lives in the `RENDER_BOUNDSFLAG=0` custom-time-bounds +field, not in REAPER's render engine.** Switching the offline render to +`RENDER_BOUNDSFLAG=2` (the project's own time selection, driven through +`GetSet_LoopTimeRange`) escapes it entirely. Confirmed by two live 48 kHz +`TailMode::None` DAW renders, both landing exactly 97627 frames against the window's own +count: the first started at the on-grid `0s` and tested only the END edge (a floored end +would have printed 97584 — 43 frames short); the second, the decisive run, started at +`2.0338983050847457s` and ended at `4.0677966101694913s`, both edges off the +millisecond grid, and no millisecond-floored model of either edge alone or both together +reproduces 97627. No compensation, trimming, or extraction was needed. +`src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection` is now the one +narrative home for the mechanism and the measurement; time selection is the only bounds +mode the offline render reaches. + +**Two hypotheses this track's originating entry previously carried are disproven, not +merely superseded** — both predicted a shortfall tracking the render's CONTENT: that the +render bounds itself to the media it can see, and that a trailing-silence trim fires +despite `RENDER_NORMALIZE`. The measured cause tracks the WINDOW instead — the exact +millisecond-floored count, independent of what the material does. Recorded so neither is +re-proposed without a fresh observation. + +**Scaffolding removed.** The experiment's apparatus — a two-position +`RenderBoundsChannel` type, a console verdict line, and a three-checkpoint +`RENDER_STARTPOS`/`RENDER_ENDPOS` read-back probe — is deleted now that the mechanism is +settled. + +**Left open, filed to `docs/TODO.md`:** `renderHonoredBounds`'s one-frame tolerance +remains empirical, not proven; and `TailMode::Auto`/`Manual` have no automatic bounds +observation at all — `render_bounds_gate` judges `TailMode::None` only, so both modes are +fixed by inference (same bounds path, same floor) rather than by measurement, and only +the 0-byte gate covers them until a DAW check closes it. diff --git a/docs/TODO.md b/docs/TODO.md index df69f65..4a383cb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -710,66 +710,80 @@ 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 — the millisecond floor +## `renderHonoredBounds`'s one-frame tolerance is empirical, not proven -**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. +**Context.** The millisecond-floor defect that motivated this gate is closed +(`docs/COMPLETED.md`), but the gate itself — `render_window.h`'s +`renderHonoredBounds` — carries a one-frame tolerance that carried through the fix +unchanged and was never itself proven. -**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. +**The wart.** 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 the 8- and 38-frame shortfalls +the floor produced (`docs/COMPLETED.md`), so it was not the cause of those +refusals — but it means a future one- or two-frame refusal may be the gate's own +edge convention rather than a real defect. -**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. +**Intended fix.** Not proposed. Widening the tolerance is a precision-invariant +decision, not a bug fix, and was deliberately not taken on speculation. -**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. +**Priority / risk.** Low. Nothing to date implicates the tolerance itself; +recorded so a future narrow refusal is investigated rather than assumed to be +the same floor. -**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. +**Done looks like.** Either the tolerance is confirmed correct by a DAW +observation that isolates edge-convention behavior from bounds-floor behavior, +or it is widened with the reasoning recorded. -**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 -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. +## `TailMode::Auto` and `Manual` have no automatic bounds observation + +**Context.** `render_bounds_gate.cpp`'s `checkRenderedBounds` returns early for +anything but `TailMode::None`, so the millisecond-floor fix (`docs/COMPLETED.md`) +was measured only against `TailMode::None` — Auto and Manual were never +observed, before the fix or after it. + +**The wart.** The inference that Auto/Manual are fixed too is sound — same +bounds path, same floor, same fix — but it is an inference, not a measurement. +`checkRenderedFileNotEmpty` runs on every tail mode and still catches a 0-byte +render, but that is the ONLY automatic bounds signal Auto/Manual get; a +floored or otherwise short-but-nonzero render under either mode would land as +`Ok` with nothing to catch it. + +**Intended fix.** Not a code change — a DAW observation. `docs/VERIFICATION.md`'s +"Capture range and bounds" section already carries the manual check: repeat an +off-grid-start capture at Manual over a source loud to the window's end and +check the landed frame count against window + `tailMs`; Auto can't be checked +by count (it trims trailing silence) and needs the null test by ear/inversion +instead. + +**Priority / risk.** Low. Both modes share the same bounds path as the +now-fixed `TailMode::None`, so nothing suggests they still floor — but nothing +confirms it either. + +**Done looks like.** A DAW-observed Auto and Manual capture, each landing the +window as requested, closes the inference into fact — or surfaces a +mode-specific divergence this entry does not currently know about. + +## Floor, ceil and round are not the identity on a millisecond grid point in binary double (caution, not an open question) + +A discarded compensation design for the millisecond-floor defect +(`docs/COMPLETED.md`) rested on the premise that a grid-aligned value survives a +bare floor/ceil/round unchanged. That is false in binary double: `1.007 * 1000 +== 1006.9999999999999` (floors to 1006, not 1007), and `4.068 * 1000 == +4067.9999999999995` (floors to 4067, not 4068). The compensation this premise +would have supported is no longer needed — the fix moved the render to a bounds +mode that does not floor at all — so this is not a live open question. Recorded +because it would bite any future millisecond-grid arithmetic that assumes an +on-grid value is safe from a bare floor: `render_window.h`'s own +`isOnMillisecondGrid`/`msFlooredEndFrameCount` already carry the nanosecond +tolerance that handles it correctly on this codebase's side of the boundary; the +trap is for whoever writes the next piece of grid arithmetic without that guard. ## `capture.cpp` is over the ~600-line ceiling — the seam is identified, taking it is blocked **Context.** Removing the settled bounds experiment's instrumentation (the console verdict and the three-checkpoint `RENDER_STARTPOS`/`ENDPOS` read-back) brought the file -from 697 to **622 measured lines**, against root `CLAUDE.md`'s ~600-line ceiling. The +from 697 to **620 measured lines**, against root `CLAUDE.md`'s ~600-line ceiling. The seam that entry originally named is gone with the instrumentation; nothing left in the file is bisectable without cutting load-bearing why. diff --git a/docs/product/capture-tail.md b/docs/product/capture-tail.md index 2d5391a..361b64e 100644 --- a/docs/product/capture-tail.md +++ b/docs/product/capture-tail.md @@ -53,12 +53,20 @@ snapshot/restore, forces dither and all normalize-postprocessing off, and render 32-bit float. The tail wires into that existing path — no new render trigger, no new backend. -### Bounds are always custom — so the tail bit is always `&1` +### Bounds are always the time selection — so the tail bit is always `&4` -The backend renders with `RENDER_BOUNDSFLAG = 0` (custom time bounds) for **every** -scope and every range type: it sets `RENDER_STARTPOS` / `RENDER_ENDPOS` explicitly -from the request's exact seconds (`capture.cpp` ~L352–354). It does **not** use the -time-selection / selected-items / regions bounds modes. +The backend renders with `RENDER_BOUNDSFLAG = 2` (time selection) for **every** +scope and every range type: it writes the request's exact seconds into the +project's own time selection via `GetSet_LoopTimeRange` (`capture.cpp` ~L470–477; +`RENDER_STARTPOS`/`RENDER_ENDPOS` are also written, as a defensive no-op for a +mode-0-only field, but the window itself travels in the time selection). It does +**not** use the custom-time-bounds mode (`RENDER_BOUNDSFLAG = 0`) — that mode was +tried and retired: DAW observation showed REAPER resolving a custom-bounds window +on a whole-millisecond grid AT RENDER TIME, flooring the end and rendering exactly +the floored frame count, which silently broke the exact-bounds precision +invariant. The time-selection mode does not floor the window. (The one narrative +home for that finding is `render_settings.h`'s `kRenderBoundsTimeSelection`; this +doc points there rather than retelling it.) `RENDER_TAILFLAG` is a bitmask keyed to the **bounds mode**, not the capture range type (header line 3047): @@ -69,18 +77,20 @@ RENDER_TAILFLAG : &1=custom time bounds, &2=entire project, &4=time selection, &32=selected project markers/regions ``` -Because we always render in custom-time-bounds mode, **the only tail bit that ever -applies is `&1`**. There is no per-range-type tail-flag decision to make — a razor -capture, a time-selection capture, and an item capture are all custom-bounds -renders under the hood, so all three take `RENDER_TAILFLAG = 1`. +Because we always render in time-selection mode, **the only tail bit that ever +applies is `&4`**. There is no per-range-type tail-flag decision to make — a razor +capture, a time-selection capture, and an item capture are all time-selection-bounds +renders under the hood, so all three take `RENDER_TAILFLAG = 4`. > **Correction to the framing brief.** The brief asked us to pick a > `RENDER_TAILFLAG` bit *per capture range type* (time selection vs. razor vs. item) > and flagged `&32` as "markers/regions." The header (line 3047) says `&32` = > *selected project regions* and `&8` = *all markers/regions* — but neither matters: -> our renders are all `RENDER_BOUNDSFLAG = 0`, so the tail bit is `&1` unconditionally. -> The existing `kTailFlagCustomBounds = 1.0` constant in `capture.cpp` (~L80) is -> already correct; the field wiring is what's missing. +> our renders are all `RENDER_BOUNDSFLAG = 2`, so the tail bit is `&4` unconditionally. +> The existing `kTailFlagTimeSelection = 4` constant in +> `src/core/capture/render_settings.h` (the bounds mode's own bit, per bounds mode — +> header line 3047) is already correct — it was right from the start; the wording +> above it (which had assumed a custom-bounds render) was what was wrong. ### Mode 1 — Automatic (default): generous tail + auto-trim to -72 dB @@ -88,7 +98,7 @@ Set, in addition to the exact `STARTPOS`/`ENDPOS` already driven: | Setting | Value | Meaning / header ref | |---|---|---| -| `RENDER_TAILFLAG` | `1` | apply tail for custom time bounds (line 3047, `&1`) | +| `RENDER_TAILFLAG` | `4` | apply tail for time selection (line 3047, `&4`) | | `RENDER_TAILMS` | `8000` | the 8 s cap, in ms (line 3048) | | `RENDER_NORMALIZE` | `32768` | **only** the trim-ending-silence bit (line 3051, `&32768`) | | `RENDER_TRIMEND` | `≈ 0.000251` | -72 dB threshold (line 3062; scaling below) | @@ -156,7 +166,7 @@ The existing (currently unwired) `CaptureRequest.renderTail` / `tailMs` fields | Setting | Value | |---|---| -| `RENDER_TAILFLAG` | `1` | +| `RENDER_TAILFLAG` | `4` | | `RENDER_TAILMS` | `request.tailMs` (clamped to the 8 s cap — see below) | | `RENDER_NORMALIZE` | `262144` (`kNormalizeDisableAll`, unchanged) | | `RENDER_TRIMEND` | not set / irrelevant (trim bit is clear) | @@ -177,9 +187,9 @@ adds a third state, so the wiring is a small enum, not a bool: - **None** (default for null-test / verify captures, and the current two-scope action defaults): `RENDER_TAILFLAG = 0`, `RENDER_TAILMS = 0`, normalize = disable-all. Exact bounds. Byte-identical to today. -- **Auto** (the new user-facing default for tail-on captures): tailFlag `1`, +- **Auto** (the new user-facing default for tail-on captures): tailFlag `4`, tailMs `8000`, normalize `32768` (surgical trim), trimEnd `0.00025119`. -- **Manual(ms)**: tailFlag `1`, tailMs `clamp(ms, 8000)`, normalize `262144` +- **Manual(ms)**: tailFlag `4`, tailMs `clamp(ms, 8000)`, normalize `262144` (disable-all), no trim. Recommended shape: replace `bool renderTail` with a `TailMode { None, Auto,