diff --git a/docs/TODO.md b/docs/TODO.md index 27a93a6..515703a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index c8e8bc0..6b6f31d 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -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 `/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 `/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 diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 32c6ca3..88abdc6 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -51,8 +51,8 @@ Detail specific to these pure modules: - `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives. - `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, 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. It also owns the two short-render diagnostics: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched, quoted by the refusal as a count coincidence and nothing more) and `describeBoundsDrift` (the sentence the offline backend prints when `RENDER_STARTPOS`/`RENDER_ENDPOS` do not read back as they were written). +- `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 bounds channel a capture hands its window over on (`RenderBoundsChannel`/`renderBoundsFlagFor`/`renderBoundsChannelLabel`), 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, 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. It also owns the short-render diagnostics: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched, quoted by the refusal as a count coincidence and nothing more), `describeBoundsDrift` (the sentence the offline backend prints when a channel's stored bounds do not read back as they were written), `isOnMillisecondGrid` (whether an observed edge can speak to a rounding question at all — an on-grid edge cannot), and `describeBoundsExperiment` (the always-printed verdict naming which bounds channel carried a capture's window and what the landed file measured). - `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. @@ -79,6 +79,12 @@ Detail specific to these pure modules: that with a transient silencing (`shell/capture/render_isolation`) whose child-set walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in `src/shell/capture/CLAUDE.md`. +- **The render window floors to the millisecond at render time.** Measured cause, + why two bounds channels exist, and the live experiment: `render_settings.h`'s + `RenderBoundsChannel` — the one narrative home; this bullet is a pointer, not a + retelling. The one fact worth keeping local: every observation to date started at + `0s`, on the grid, so **nothing is known about whether the start floors too** — + assume neither. - `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline render has no true pre-FX "dry" bit; FX scoping is done entirely by the FX-bypass-around-render mechanism, never by a render bit. diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index 5cec2f2..83ca559 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -14,7 +14,36 @@ double autoTrimEndRatio() { return std::pow(10.0, kAutoTrimThresholdDb / 20.0); } -TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { +int renderBoundsFlagFor(RenderBoundsChannel channel) { + switch (channel) { + case RenderBoundsChannel::CustomTimeBounds: return 0; + case RenderBoundsChannel::TimeSelection: return 2; + } + // Unreachable for a valid enum; fail closed to the channel every shipped capture + // rendered on, never to a mode that bounds itself off something else entirely. + return 0; +} + +int tailFlagBitFor(RenderBoundsChannel channel) { + switch (channel) { + case RenderBoundsChannel::CustomTimeBounds: return kTailFlagCustomBounds; + case RenderBoundsChannel::TimeSelection: return kTailFlagTimeSelection; + } + return kTailFlagCustomBounds; // paired with renderBoundsFlagFor's fallback +} + +const char* renderBoundsChannelLabel(RenderBoundsChannel channel) { + switch (channel) { + case RenderBoundsChannel::CustomTimeBounds: + return "custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS)"; + case RenderBoundsChannel::TimeSelection: + return "time selection (RENDER_BOUNDSFLAG=2, GetSet_LoopTimeRange)"; + } + return "unnamed bounds channel"; +} + +TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs, + RenderBoundsChannel channel) { TailRenderSettings t; switch (mode) { case TailMode::None: @@ -30,7 +59,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { // postprocessing bit clear. A fixed-threshold trim scales/limits/fades // nothing, so identical requests trim at the identical sample -> holds // the bit-identical-repeats invariant. - t.tailFlag = kTailFlagCustomBounds; + t.tailFlag = tailFlagBitFor(channel); t.tailMs = kMaxTailMs; t.normalize = kNormalizeTrimEnd; t.trimEnd = autoTrimEndRatio(); @@ -38,7 +67,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { case TailMode::Manual: // Clamped to the cap regardless of source; negative floors to 0. - t.tailFlag = kTailFlagCustomBounds; + t.tailFlag = tailFlagBitFor(channel); t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.normalize = kNormalizeDisableAll; t.trimEnd = 0.0; @@ -114,6 +143,10 @@ const char* renderSourceLabel(SourceMode mode) { return "unknown"; // unreachable for a valid enum; never claim a source } +bool sourceBypassesBoundsChannel(SourceMode mode) { + return mode == SourceMode::SelectedItems || mode == SourceMode::RazorArea; +} + SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) { switch (scope) { case CaptureScope::Item: diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 6115a63..cc04cfc 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -27,17 +27,51 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e // render wet; the scope decides which FX remain enabled. inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file +// --- Render bounds channel ---------------------------------------------------- +// +// The RENDER_BOUNDSFLAG mode a capture hands its window over on (values verbatim, +// header ~3042: 0 = custom time bounds, 2 = time selection). RENDER_STARTPOS / +// RENDER_ENDPOS apply to mode 0 ONLY (header ~3045-3046), so the TimeSelection +// channel carries the window in the project's own time selection instead — a +// different store. That difference is the whole reason two channels exist: REAPER +// resolved a custom-bounds window on a whole-millisecond grid, floored the end, wrote +// the floored value back over RENDER_ENDPOS, and rendered exactly the floored frame +// count — twice, to the frame (docs/TODO.md "An offline capture can be refused..." +// records the observations). The same read-back at store time and immediately before +// the render was silent, so the field itself holds full double precision and the floor +// happens at render time. Whether that floor sits in the custom-bounds channel or +// downstream in the render engine (where no bounds mode escapes it) cannot be settled +// from the SDK header, only in a DAW. The offline backend therefore names the channel +// it used and what the landed file measured (render_window::describeBoundsExperiment) +// so one smoke run answers it. This is the one narrative home for why two channels +// exist; other sites point here rather than retelling it. +enum class RenderBoundsChannel { + CustomTimeBounds, + TimeSelection, +}; + +// The RENDER_BOUNDSFLAG value for a channel. +int renderBoundsFlagFor(RenderBoundsChannel channel); + +// The channel in words, for the console verdict. +const char* renderBoundsChannelLabel(RenderBoundsChannel channel); + // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // -// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG, -// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE +// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047), so the bit a +// tail mode has to set follows the bounds channel the window went over — a tail +// set under the other channel's bit renders no tail at all. RENDER_NORMALIZE // (verbatim, header ~3051): &32768 = trim ending silence (Auto path); // &(4<<16) = disable all render postprocessing (None/Manual path). inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all -inline constexpr int kTailFlagNone = 0; -inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047 +inline constexpr int kTailFlagNone = 0; +inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047 +inline constexpr int kTailFlagTimeSelection = 4; // &4, header ~3047 + +// The RENDER_TAILFLAG bit that applies to a channel's bounds mode. +int tailFlagBitFor(RenderBoundsChannel channel); // Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND // ratio derives from this dB, never the reverse). Daniel-set. @@ -65,15 +99,18 @@ enum class TailMode { // normalize bit is set (Auto). The backend reads these straight onto // GetSetProjectInfo. struct TailRenderSettings { - int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1) + int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the channel's bit) double tailMs = 0.0; // RENDER_TAILMS int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set) }; // Maps a tail mode (+ requested manual tail ms, used only for Manual) to its -// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. -TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); +// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. `channel` +// is a parameter rather than a caller-side OR so a bounds-channel change cannot +// leave Auto/Manual setting a tail bit the render no longer reads. +TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs, + RenderBoundsChannel channel); // The realtime record-window end (project seconds): realtime does NOT drive // RENDER_*, it records a generous window and trims later, so this is where the @@ -108,6 +145,18 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); // naming them apart would assert a render distinction that does not exist. const char* renderSourceLabel(SourceMode mode); +// True for a render source INFERRED (not SDK-confirmed) to derive its bounds from +// content rather than RENDER_BOUNDSFLAG: SelectedItems (&32), per the observed-defect +// inference in src/core/capture/CLAUDE.md §Gotchas, and RazorArea (&4096) by analogy to +// it — the SDK header (~3042) actually separates bounds (RENDER_BOUNDSFLAG, its own +// value 4 = selected media items) from source (&32), which leans the other way. +// RazorArea stays in the set on that inference even though no offline capture path +// assigns it today — razor is a RANGE source (capture_batch.cpp's razor units render +// through track scope), not a render source of its own. A capture on either never +// consults RenderBoundsChannel, so describeBoundsExperiment's verdict must not be read +// as evidence about the channel — the caller names the source instead. +bool sourceBypassesBoundsChannel(SourceMode mode); + // --- Capture scope: the FX-scope invariant ------------------------------------ // // See src/core/capture/CLAUDE.md for the scope contract. There is NO master diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index 40e2840..8e92b36 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -18,9 +18,8 @@ long long frameIndexAt(double seconds, int sampleRate) { // See the header for why whole milliseconds get a tolerance and why it is this small. double floorToMilliseconds(double seconds) { - const double ms = seconds * 1000.0; - const double nearest = std::nearbyint(ms); - if (std::fabs(ms - nearest) < 1e-6) return nearest / 1000.0; + const double ms = seconds * 1000.0; + if (isOnMillisecondGrid(seconds)) return std::nearbyint(ms) / 1000.0; return std::floor(ms) / 1000.0; } @@ -58,11 +57,113 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, && frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate); } +bool isOnMillisecondGrid(double seconds) { + const double ms = seconds * 1000.0; + return std::fabs(ms - std::nearbyint(ms)) < 1e-6; +} + long long msFlooredEndFrameCount(double startSeconds, double endSeconds, int sampleRate) { return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate); } +std::string describeBoundsExperiment(const char* channelLabel, + double reqStart, double reqEnd, + long long actualFrames, int sampleRate, + const char* bypassingSourceLabel) { + std::string s = "bounds channel: "; + s += (channelLabel && channelLabel[0]) ? channelLabel : "unnamed bounds channel"; + s += ". "; + + if (bypassingSourceLabel && bypassingSourceLabel[0]) { + s += "NOT JUDGED -- this capture's render source is " + + std::string(bypassingSourceLabel) + + ", INFERRED (not SDK-confirmed) to derive its bounds from content rather" + " than consult this channel; this capture is not evidence either way about it."; + return s; + } + + if (sampleRate <= 0) { + s += "NOT JUDGED -- this capture's frames were never counted against the " + "window, so this capture is not evidence either way about the channel."; + return s; + } + + const long long expected = frameCountFor(reqStart, reqEnd, sampleRate); + const long long delta = actualFrames - expected; + + // A window under a frame at this rate has nothing to compare: a 0-frame render + // against a 0-frame window is a coincidence of degenerate inputs, not a match. + if (expected == 0 && actualFrames == 0) { + s += "NOT JUDGED -- the requested window rounds to 0 frames at this rate, so a " + "0-frame render is not evidence either way about the channel."; + return s; + } + + // Within the gate's own edge-convention slack (render_window.h): its normal + // tolerance, not evidence the millisecond floor was escaped or hit. + const bool withinTolerance = + delta != 0 && renderHonoredBounds(expected, actualFrames); + s += (delta == 0) ? "EXACT" : (delta < 0 ? "SHORT" : "LONG"); + if (withinTolerance) s += " (WITHIN TOLERANCE)"; + s += " -- the landed render holds " + std::to_string(actualFrames) + + " frames against the " + std::to_string(expected) + + " the window asks for at " + std::to_string(sampleRate) + " Hz."; + + // The shape both live short renders matched to the frame. A match says this channel + // produced a floored window; it does not locate where inside REAPER the floor is. A + // floor only removes frames, so this can only ever match a SHORT, never a LONG. + if (delta < 0) { + const long long msFloored = + msFlooredEndFrameCount(reqStart, reqEnd, sampleRate); + if (msFloored > 0 && actualFrames == msFloored) + s += " That is exactly the count this window holds with its end floored to" + " the millisecond -- the shape REAPER's render was measured producing."; + } + + s += isOnMillisecondGrid(reqStart) + ? " The START edge is UNTESTED here: " + exactly(reqStart) + + "s is already on the millisecond grid, which floor, ceil and round all leave" + " alone. Re-run over a range starting off the grid to test it." + : " The START edge IS tested here: " + exactly(reqStart) + + "s carries a sub-millisecond remainder."; + + // EXACT is proof only when no millisecond-floored model of this window could have + // produced the same count. Grid membership on an edge is a PROXY for that collision, + // not the test itself: sub-millisecond remainders on the two edges can cancel under + // a full floor even when neither edge is on the grid (a dragged, fixed-length time + // selection reproduces this), and a remainder under half a frame collides with a + // floored edge without ever registering as off-grid. Enumerate every floored model + // directly rather than inferring from grid membership. + if (delta == 0) { + const double flooredStart = floorToMilliseconds(reqStart); + const double flooredEnd = floorToMilliseconds(reqEnd); + const bool startAlone = + actualFrames == frameCountFor(flooredStart, reqEnd, sampleRate); + const bool endAlone = + actualFrames == frameCountFor(reqStart, flooredEnd, sampleRate); + const bool bothTogether = + actualFrames == frameCountFor(flooredStart, flooredEnd, sampleRate); + + if (startAlone || endAlone || bothTogether) { + std::string models; + auto addModel = [&](const char* label) { + if (!models.empty()) models += ", or "; + models += label; + }; + if (startAlone) addModel("floors the START edge alone"); + if (endAlone) addModel("floors the END edge alone"); + if (bothTogether) addModel("floors START and END together"); + + s += " EXACT here is not proof: a render that " + models + + " to the millisecond would print this identical count -- re-run over a" + " window where a floored edge would show a different count before" + " reading EXACT as the fix."; + } + } + return s; +} + std::string describeBoundsDrift(double reqStart, double reqEnd, double storedStart, double storedEnd, int sampleRate) { diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index 3f96e6f..5e64d90 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -1,10 +1,10 @@ #pragma once // render_window — pure frame arithmetic for a capture's requested window: the // frame count a project-time range occupies, whether a render whose bounds come -// from the selected items' own extent already prints that window, and the two -// diagnostics that bound a short render without locating it: whether the stored -// RENDER_* bounds round-tripped, and whether the shortfall matches a millisecond- -// floor coincidence. +// from the selected items' own extent already prints that window, and the +// diagnostics that bound a short render: whether the stored bounds round-tripped, +// whether the shortfall matches a millisecond-floor coincidence, and the verdict on +// which bounds channel a capture used and what it produced. // NO REAPER types; unit-tested by tests/test_render_window.cpp. #include @@ -50,18 +50,77 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, // --- Diagnostics: where a short render lost its frames ------------------------ // The frames this window would hold if its END were resolved on a whole-millisecond -// grid, floored, instead of exactly. Two live short renders (48 kHz, TailMode::None) -// matched this count to the frame, which is the entire reason it exists. +// grid, floored, instead of exactly. That is what REAPER's offline render does: two +// live short renders (48 kHz, TailMode::None) printed this count to the frame, and the +// after-render read-back showed REAPER's own resolved end floored to the same value. // -// A COINCIDENCE OF COUNTS, not a claim about how anything resolved the end: nothing -// renders from this number and no capture path asks for it. Whole-millisecond values +// Still a DESCRIPTION, never a request: nothing renders from this number and no capture +// path asks for it — a refusal quotes it to say the shortfall has the known shape, which +// is not the same as proving that this particular render took it. Whole-millisecond values // are recognized within a nanosecond, because a decimal millisecond is not always one // in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a // millisecond from a window already on the grid. A nanosecond is far under one frame // at any rate we render, so a real sub-millisecond remainder still floors. +// +// The tolerance is ours, not REAPER's: on a `1.007`-class grid point, a REAPER floor +// that does NOT carry the same epsilon would miss this shape entirely, and a real +// floored render would then read as an unmatched SHORT rather than the known one — +// silence here is not proof the floor didn't happen (docs/TODO.md records why this +// premise needs a DAW measurement before anything is built on it). long long msFlooredEndFrameCount(double startSeconds, double endSeconds, int sampleRate); +// True when `seconds` sits on a whole-millisecond boundary, under the same nanosecond +// tolerance msFlooredEndFrameCount uses and for the same reason (stated there). +// +// Load-bearing for reading a bounds observation: an on-grid edge is left alone by +// floor, ceil and round alike, so a window whose START is on the grid can say nothing +// about whether REAPER resolves the start edge the way it resolves the end. +bool isOnMillisecondGrid(double seconds); + +// The one-line verdict on what a capture's bounds channel did with its window: which +// channel carried it (or, when the render source defines the window itself, which +// source bypassed the channel entirely), the frames the landed file holds against the +// frames the window asks for, and whether this run could test the START and END edges +// at all. Always non-empty — a capture that answered nothing has to say so, or its +// silence reads as a pass. +// +// EXACT never stands alone as proof: the observed count is checked against every +// millisecond-floored model of the same window (start floored alone, end floored alone, +// both together), and any model that reproduces it is named in the sentence. Grid +// membership on an edge is a PROXY for that collision, not the test itself — remainders +// on the two edges can cancel under a full floor even when NEITHER edge sits on the +// grid, and a remainder under half a frame collides with a floored edge without ever +// registering as off-grid at all. Checking the models directly is what a grid test on +// either edge alone cannot do. +// +// A non-zero delta that still falls inside the gate's own tolerance (renderHonoredBounds) +// is tagged "(WITHIN TOLERANCE)" — that is the gate's ordinary edge-convention slack, not +// evidence of the millisecond floor. The floor's signature is ONLY the "floored to the +// millisecond" sentence (a delta matching msFlooredEndFrameCount exactly); 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. A +// window whose start and end sit in the same millisecond bucket makes msFlooredEndFrameCount +// equal the exact count, so a real one-frame floor there reads as a bare SHORT (WITHIN +// TOLERANCE) with no floor sentence at all — that combination is not evidence the floor +// didn't happen, just a case this diagnostic can't see into. +// +// `sampleRate <= 0` means the landed file was never measured: a tail mode adds frames by +// design and is not judged, an empty render has none, and a render whose layout failed to +// parse or declared no sample rate is refused before it can be judged either — the +// sentence then says the run answered nothing rather than inventing a comparison. A +// window that rounds to 0 frames at this rate reads NOT JUDGED the same way: a 0-frame +// render against a 0-frame window is not a comparison either. +// +// `bypassingSourceLabel`, when non-null and non-empty, means the render source itself +// defined the window (render_settings::sourceBypassesBoundsChannel) — `channelLabel` was +// never consulted, so the verdict names the source instead and reads NOT JUDGED +// regardless of how the frame counts compare. +std::string describeBoundsExperiment(const char* channelLabel, + double reqStart, double reqEnd, + long long actualFrames, int sampleRate, + const char* bypassingSourceLabel = nullptr); + // The sentence a capture prints when the render bounds it handed REAPER did not read // back unchanged — the requested window, what came back, and both frame counts at // `sampleRate` (omitted when the rate is unknown). EMPTY when both edges read back diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 9814735..622e013 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -36,7 +36,14 @@ detail not covered there: - **`renderOffline` is the one seam both a fresh capture and a recipe replay cross**, which is why the refusal and both transient guards live there rather than in the action bodies — anything placed in `ResolveScopeSource` alone would - miss `RunRecaptureFromSource` entirely. + miss `RunRecaptureFromSource` entirely. The bounds channel is inside the backend + that seam calls, for the same reason: a replay must hand its window over exactly + the way a fresh capture does. +- **The bounds channel is under live experiment**, and `capture.cpp`'s + `kBoundsChannel` is its single switch. On the time-selection channel the render + window travels in the project's own time selection, so `capture` snapshots and + restores that selection like any other state it borrows. Why there are two + channels: `src/core/capture/render_settings.h`'s `RenderBoundsChannel`. - **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard neutralizes the in-scope chain — provenance must see the chain as it really is, diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 6585ed7..2191a55 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -6,8 +6,9 @@ // the one TU that defines the API pointers; here they are extern. // // Drives the RENDER_* project settings via GetSetProjectInfo/_String (source- -// selection bits come from the pure render_settings mapping), snapshots and -// restores every setting it changes, triggers a render, then populates a Sample. +// selection bits come from the pure render_settings mapping) plus, on the +// time-selection bounds channel, the project time selection; snapshots and restores +// every one of them, triggers a render, then populates a Sample. // Source-agnostic: never reads the DAW selection itself, only the CaptureRequest // the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never // inserts into the arrange. @@ -62,9 +63,13 @@ namespace { // project — why we set them all explicitly first. constexpr int kActionRenderUsingMostRecentSettings = 42230; -// RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves -// for exact, unrounded bounds). SDK header ~3042. -constexpr double kBoundsCustom = 0.0; +// The bounds channel this build hands the render window over on. Why there are two, +// and the open DAW question this selection exists to answer, are stated once on +// RenderBoundsChannel (core/capture/render_settings.h) — flipping this constant back +// to CustomTimeBounds is the whole revert. +constexpr RenderBoundsChannel kBoundsChannel = RenderBoundsChannel::TimeSelection; +constexpr bool kUsesTimeSelectionBounds = + (kBoundsChannel == RenderBoundsChannel::TimeSelection); // RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure // tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below. @@ -177,6 +182,30 @@ void restoreRenderSettings(const RenderSettingsSnapshot& s) { GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true); } +// The project time selection, snapshotted and restored around a render that uses it +// as its bounds channel. Separate from ScopedRenderSettings because it is project +// state rather than a RENDER_* setting, and only one channel touches it. +// GetSet_LoopTimeRange has no project parameter (SDK header ~2670) — it acts on the +// active project, which is the one capture() already resolved and renders into. +struct ScopedTimeSelection { + bool engaged; + double start = 0.0; + double end = 0.0; + + explicit ScopedTimeSelection(bool engage) : engaged(engage) { + if (engaged) GetSet_LoopTimeRange(false, false, &start, &end, false); + } + ~ScopedTimeSelection() { + if (!engaged) return; + // Copies: the setter takes non-const pointers, so the snapshot must not be + // what it writes through. + double s = start, e = end; + GetSet_LoopTimeRange(true, false, &s, &e, false); + } + ScopedTimeSelection(const ScopedTimeSelection&) = delete; + ScopedTimeSelection& operator=(const ScopedTimeSelection&) = delete; +}; + // RAII wrapper: guarantees restore on every return path from capture(). struct ScopedRenderSettings { RenderSettingsSnapshot snap; @@ -440,18 +469,38 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { } ScopedRenderSettings guard(proj); + ScopedTimeSelection tsGuard(kUsesTimeSelectionBounds); - // Custom time bounds so the rendered length equals the requested range with - // NO rounding and NO added silence (unless a tail was explicitly requested). - GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true); + // The window goes over the selected channel's own store so the rendered length + // equals the requested range with NO rounding and NO added silence (unless a tail + // was explicitly requested). RENDER_STARTPOS/ENDPOS are written on BOTH channels: + // they are documented as applying to mode 0 only (SDK header ~3045-3046), so under + // the time-selection channel they are inert, and their read-back below then reports + // that field independently of the one actually carrying the window. + GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", + static_cast(renderBoundsFlagFor(kBoundsChannel)), true); GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true); + if (kUsesTimeSelectionBounds) { + double s = request.startSeconds, e = request.endSeconds; + GetSet_LoopTimeRange(true, false, &s, &e, false); + } + + // The time-selection channel's read-back, so each checkpoint below reads the store + // that actually carried the window rather than the inert RENDER_* pair. + auto readTimeSelection = [](double& s, double& e) { + s = 0.0; + e = 0.0; + GetSet_LoopTimeRange(false, false, &s, &e, false); + }; // The requested window crosses out of this process HERE and nowhere else, so the // read-back is the only evidence available on this side of that boundary for // whether REAPER kept it. Reported below, once the project rate is known. const double storedStart = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false); const double storedEnd = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false); + double storedTsStart = 0.0, storedTsEnd = 0.0; + if (kUsesTimeSelectionBounds) readTimeSelection(storedTsStart, storedTsEnd); // TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact // bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end @@ -459,7 +508,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // trim. NORMALIZE is driven here (not the determinism block below) so the // Auto surgical value isn't clobbered. const TailRenderSettings tail = - tailRenderSettingsFor(request.tailMode, request.tailMs); + tailRenderSettingsFor(request.tailMode, request.tailMs, kBoundsChannel); GetSetProjectInfo(proj, "RENDER_TAILFLAG", static_cast(tail.tailFlag), true); GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true); @@ -496,7 +545,19 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { ShowConsoleMsg(("ReaSampler capture (" + std::string(checkpoint) + "): " + drift + "\n").c_str()); }; - reportDrift("at store", storedStart, storedEnd); + + // The same checkpoint on the other channel. No-op unless that channel is the one + // carrying the window, so the console gains nothing on the custom-bounds build. + auto reportTimeSelectionDrift = [&](const char* checkpoint) { + if (!kUsesTimeSelectionBounds) return; + double s = 0.0, e = 0.0; + readTimeSelection(s, e); + reportDrift(checkpoint, s, e); + }; + + reportDrift("at store, custom-bounds fields", storedStart, storedEnd); + if (kUsesTimeSelectionBounds) + reportDrift("at store, time selection", storedTsStart, storedTsEnd); GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(request.channelCount), true); @@ -519,10 +580,37 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { setProjString(proj, "RENDER_FILE", paths.absoluteDir); setProjString(proj, "RENDER_PATTERN", paths.fileStem); + // Which bounds channel carried this window and what the render did with it — printed + // on EVERY tail mode and on EVERY return past this point (refusals included), + // because a verdict that appeared only on some outcomes would read its own absence + // on the rest as a pass. Auto/Manual are not judged against a frame count (they add + // frames by design) and the sentence says so rather than comparing anyway. + // SelectedItems/RazorArea are INFERRED (not SDK-confirmed) to derive their bounds + // from content and never consult the channel at all + // (render_settings::sourceBypassesBoundsChannel) — the batch-item path renders + // through SelectedItems on every capture, so without this the verdict would print an + // EXACT/SHORT/LONG claim about a channel that was never in play. + const char* boundsBypassLabel = sourceBypassesBoundsChannel(request.sourceMode) + ? renderSourceLabel(request.sourceMode) + : nullptr; + auto printBoundsVerdict = [&](long long frames, int rate) { + ShowConsoleMsg(("ReaSampler capture -- " + + describeBoundsExperiment(renderBoundsChannelLabel(kBoundsChannel), + request.startSeconds, request.endSeconds, + frames, rate, boundsBypassLabel) + + "\n").c_str()); + }; + auto reportExperiment = [&](const BoundsVerdict& v) { + printBoundsVerdict(v.measuredFrames, v.measuredRate); + }; + // Int16/Int24 have no captured ground-truth blob — fail explicitly rather // than silently mis-render at the wrong bit depth. const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth); if (!fmtBase64) { + // Nothing was rendered yet — frames/rate 0 reads as NOT JUDGED, same as any + // other capture that answered nothing. + printBoundsVerdict(0, 0); result.status = CaptureStatus::UnsupportedFormat; result.message = "Requested bit depth has no verified RENDER_FORMAT blob " "(Float32 only; Int16/Int24 not yet supported)."; @@ -532,24 +620,30 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // Read again right here: a mismatch against the store-time read-back above // means something between the two writes and this line moved the bounds, - // before the render ever ran. - reportDrift("before render", + // before the render ever ran. Both channels get the same three checkpoints, or a + // store-versus-render distinction would only be available on one of them. + reportDrift("before render, custom-bounds fields", GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false), GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false)); + reportTimeSelectionDrift("before render, time selection"); Main_OnCommand(kActionRenderUsingMostRecentSettings, 0); // And once more here, while the guard above is still live and before it restores // anything: only the gap between this read and the one immediately above can be // the render itself. - reportDrift("after render", + reportDrift("after render, custom-bounds fields", GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false), GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false)); + reportTimeSelectionDrift("after render, time selection"); // Main_OnCommand returns void, so a failed render is silent — stat the // expected output path to detect it. const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName; if (!std::filesystem::exists(expectedPath)) { + // Main_OnCommand ran but produced nothing measurable — NOT JUDGED, same as + // the format refusal above. + printBoundsVerdict(0, 0); result.status = CaptureStatus::RenderFailed; result.message = "Render produced no output file (expected: " + expectedPath + "). Check the REAPER console for errors."; @@ -563,6 +657,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { const BoundsVerdict emptyVerdict = checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination); if (emptyVerdict.refused) { + reportExperiment(emptyVerdict); result.status = CaptureStatus::BoundsMismatch; result.message = emptyVerdict.message; return result; @@ -576,6 +671,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // on an already-warm file, judged acceptable.) const BoundsVerdict bounds = checkRenderedBounds(expectedPath, projectDir, request); + reportExperiment(bounds); if (bounds.refused) { result.status = CaptureStatus::BoundsMismatch; result.message = bounds.message; diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 09ca4c7..edf0a5c 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -231,7 +231,8 @@ CaptureResult renderOffline(CaptureScope scope, // caller can report success/failure. Load-bearing principle holds: writes a file + // a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the // out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), -// and the backend restores every RENDER_* setting. +// and the backend restores every RENDER_* setting it changed plus, on the +// time-selection bounds channel, the project time selection it borrowed. // // On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id // on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index b67dc4b..158a9d5 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -117,22 +117,23 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const long long expectedFrames = frameCountFor(request.startSeconds, request.endSeconds, rate); const long long actualFrames = static_cast(layout.frameCount()); + v.measuredFrames = actualFrames; + v.measuredRate = rate; if (renderHonoredBounds(expectedFrames, actualFrames)) return v; - // Says whether this shortfall has the one shape two live short renders already - // matched to the frame: the END alone floored to the millisecond. Checked against - // the END only -- a refusal whose START is also off-grid and independently floored - // would not match this shape, and this note's silence on that refusal is this - // check not covering it, not the coincidence breaking. Excludes 0, which every - // sub-millisecond window (a legitimate day-one capture) also floors to, and which - // would otherwise match a render that produced nothing. A count coincidence only — - // it does not establish how the render resolved anything. + // Says whether this shortfall has the known shape: the END alone floored to the + // millisecond, which is what REAPER's render was measured doing. Checked against the + // END only -- a refusal whose START is also off-grid and independently floored would + // not match this shape, and this note's silence on that refusal is this check not + // covering it. Excludes 0, which every sub-millisecond window (a legitimate day-one + // capture) also floors to, and which would otherwise match a render that produced + // nothing. const long long msFlooredEnd = msFlooredEndFrameCount(request.startSeconds, request.endSeconds, rate); const std::string msNote = (msFlooredEnd > 0 && actualFrames == msFlooredEnd) ? " Those are exactly the frames this window holds with its end floored to" - " the millisecond -- a match on the count, not a measured cause." + " the millisecond -- the shape REAPER's render was measured producing." : std::string(); v.refused = true; diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index 55a3433..a82b48c 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -18,6 +18,13 @@ namespace reasampler::capture { struct BoundsVerdict { bool refused = false; std::string message; // console text; meaningful only when refused + + // What the landed file measured, when this verdict measured it at all. A rate of 0 + // means it did not — a tail mode is not judged here, the parse failed, or this is + // the emptiness check, which counts no frames. Carried so the backend's bounds + // verdict can report the count without opening the file again. + long long measuredFrames = 0; + int measuredRate = 0; }; // Judges `renderedPath` against `request`'s window. Refuses on two counts: the file's diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index e524eb3..f048265 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -111,18 +111,26 @@ static void testLabelsSeparateExactlyWhatTheRenderSeparates() { // --- tail: TailMode -> RENDER_* mapping (docs/product/capture-tail.md) -------- +// The tail assertions below are about the MODE's mapping; the one value that also +// depends on the bounds channel has its own test, so they all pin the channel that +// every shipped capture rendered on. +static TailRenderSettings tailFor(TailMode mode, double manualTailMs) { + return tailRenderSettingsFor(mode, manualTailMs, + RenderBoundsChannel::CustomTimeBounds); +} + static void testTailNoneIsExactBounds() { // None -> exact bounds, byte-identical to the pre-tail capture: tail flag clear, // 0 ms, disable-all normalize (the current default), no trim. Asserting the exact // bit values (not just "some value") pins the byte-identical contract: if the // mapping regressed to set a tail bit or a non-disable-all normalize, this fails. - TailRenderSettings t = tailRenderSettingsFor(TailMode::None, 0.0); + TailRenderSettings t = tailFor(TailMode::None, 0.0); CHECK(t.tailFlag == kTailFlagNone); // 0 CHECK(t.tailMs == 0.0); CHECK(t.normalize == kNormalizeDisableAll); // 262144 CHECK(t.trimEnd == 0.0); // manualTailMs must be ignored for None (a stray tail from a leftover ms is the bug). - TailRenderSettings t2 = tailRenderSettingsFor(TailMode::None, 5000.0); + TailRenderSettings t2 = tailFor(TailMode::None, 5000.0); CHECK(t2.tailFlag == kTailFlagNone); CHECK(t2.tailMs == 0.0); } @@ -131,7 +139,7 @@ static void testTailAutoIsSurgicalTrim() { // Auto -> custom-bounds tail bit, 8 s cap, SURGICAL normalize (ONLY &32768), and // the -72 dB TRIMEND ratio. The disable-all bit must NOT be set (it is semantically // opposed to trim — this assertion catches a regression to the None normalize). - TailRenderSettings t = tailRenderSettingsFor(TailMode::Auto, 0.0); + TailRenderSettings t = tailFor(TailMode::Auto, 0.0); CHECK(t.tailFlag == kTailFlagCustomBounds); // &1 CHECK(t.tailMs == kMaxTailMs); // 8000 CHECK(t.normalize == kNormalizeTrimEnd); // exactly 32768, nothing else @@ -139,7 +147,7 @@ static void testTailAutoIsSurgicalTrim() { // TRIMEND is the derived -72 dB ratio ~= 0.00025119 (the DAW-confirm value). CHECK(std::fabs(t.trimEnd - 0.00025119) < 1e-8); // manualTailMs is ignored for Auto (Auto always uses the 8 s cap). - CHECK(tailRenderSettingsFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs); + CHECK(tailFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs); } static void testAutoTrimRatioDerivesFromDb() { @@ -147,7 +155,7 @@ static void testAutoTrimRatioDerivesFromDb() { // float — recompute it independently and require an exact match with the mapping. double expected = std::pow(10.0, kAutoTrimThresholdDb / 20.0); CHECK(autoTrimEndRatio() == expected); - CHECK(tailRenderSettingsFor(TailMode::Auto, 0.0).trimEnd == expected); + CHECK(tailFor(TailMode::Auto, 0.0).trimEnd == expected); // Sanity: -72 dB is well below unity but above zero. CHECK(expected > 0.0 && expected < 0.001); } @@ -156,7 +164,7 @@ static void testTailManualFixedNoTrim() { // Manual -> custom-bounds tail, the requested ms (within cap), disable-all // normalize (no trim). A Manual capture is a fixed tail, so it keeps today's // disable-all exactly like the no-tail path. - TailRenderSettings t = tailRenderSettingsFor(TailMode::Manual, 2500.0); + TailRenderSettings t = tailFor(TailMode::Manual, 2500.0); CHECK(t.tailFlag == kTailFlagCustomBounds); CHECK(t.tailMs == 2500.0); CHECK(t.normalize == kNormalizeDisableAll); @@ -165,12 +173,67 @@ static void testTailManualFixedNoTrim() { static void testTailManualClampsToCap() { // The 8 s cap is a runaway guard that applies to Manual too: ms > 8000 -> 8000. - CHECK(tailRenderSettingsFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs); - CHECK(tailRenderSettingsFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs); + CHECK(tailFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs); + CHECK(tailFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs); // Below the cap is passed through unchanged. - CHECK(tailRenderSettingsFor(TailMode::Manual, 100.0).tailMs == 100.0); + CHECK(tailFor(TailMode::Manual, 100.0).tailMs == 100.0); // A negative request floors to 0 (no negative tail leaks into RENDER_TAILMS). - CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0); + CHECK(tailFor(TailMode::Manual, -50.0).tailMs == 0.0); +} + +// --- bounds channel: RENDER_BOUNDSFLAG mode + the tail bit it drags along ------ + +static void testEachChannelNamesItsOwnBoundsFlagMode() { + // The two RENDER_BOUNDSFLAG values, as literals from the SDK header — 0 = custom + // time bounds, 2 = time selection. Pinned as numbers so a renumbering of the enum + // cannot silently point a capture at "entire project" or "selected media items". + CHECK(renderBoundsFlagFor(RenderBoundsChannel::CustomTimeBounds) == 0); + CHECK(renderBoundsFlagFor(RenderBoundsChannel::TimeSelection) == 2); +} + +static void testTailBitFollowsTheBoundsChannel() { + // RENDER_TAILFLAG's bits are per-bounds-mode: &1 covers custom time bounds, &4 + // covers the time selection. A tail set under the other channel's bit renders no + // tail at all, which is why the mapping takes the channel rather than trusting a + // caller to OR the right one in. + CHECK(tailFlagBitFor(RenderBoundsChannel::CustomTimeBounds) == 1); + CHECK(tailFlagBitFor(RenderBoundsChannel::TimeSelection) == 4); + + // Both tail-bearing modes follow it — a fix applied to Auto alone would leave + // Manual rendering under a bit the bounds mode does not read. + for (TailMode mode : {TailMode::Auto, TailMode::Manual}) { + CHECK(tailRenderSettingsFor(mode, 2500.0, + RenderBoundsChannel::CustomTimeBounds) + .tailFlag == kTailFlagCustomBounds); + CHECK(tailRenderSettingsFor(mode, 2500.0, + RenderBoundsChannel::TimeSelection) + .tailFlag == kTailFlagTimeSelection); + } +} + +static void testNoneSetsNoTailBitOnEitherChannel() { + // None is exact bounds on every channel: no tail bit, so no channel's bit either. + CHECK(tailRenderSettingsFor(TailMode::None, 5000.0, + RenderBoundsChannel::CustomTimeBounds) + .tailFlag == kTailFlagNone); + CHECK(tailRenderSettingsFor(TailMode::None, 5000.0, + RenderBoundsChannel::TimeSelection) + .tailFlag == kTailFlagNone); +} + +static void testTheChannelLabelNamesTheModeAndItsStore() { + // The console verdict is read by someone deciding which channel to keep, so the + // label has to name both the mode number and where the window actually went. + const std::string custom = renderBoundsChannelLabel(RenderBoundsChannel::CustomTimeBounds); + CHECK(custom.find("RENDER_BOUNDSFLAG=0") != std::string::npos); + CHECK(custom.find("RENDER_STARTPOS") != std::string::npos); + + const std::string ts = renderBoundsChannelLabel(RenderBoundsChannel::TimeSelection); + CHECK(ts.find("RENDER_BOUNDSFLAG=2") != std::string::npos); + CHECK(ts.find("GetSet_LoopTimeRange") != std::string::npos); + + // Two channels that read alike in the console would make the experiment unreadable. + CHECK(custom != ts); } // --- realtimeRecordWindowEnd: the T2 record-window extension ----------------- @@ -321,6 +384,23 @@ static void testMultiTrackStemRenderIsNamedForRefusal() { CHECK(!isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, true), 2)); } +static void testSourceBypassesBoundsChannelOnlyForContentDerivedSources() { + // SelectedItems (&32) and RazorArea (&4096) derive their bounds from the + // selected items'/areas' own extents -- RENDER_BOUNDSFLAG is never consulted, so + // a bounds-channel verdict is not evidence for either (the regression this + // predicate exists to catch: RunBatchCaptureItems always renders through + // SelectedItems, so this false-EXACT would fire on every batch-item capture). + CHECK(sourceBypassesBoundsChannel(SourceMode::SelectedItems)); + CHECK(sourceBypassesBoundsChannel(SourceMode::RazorArea)); + + // Every other source is genuinely time-bounded through RENDER_STARTPOS/ENDPOS or + // the time selection, so the channel IS the evidence for these. + CHECK(!sourceBypassesBoundsChannel(SourceMode::MasterMix)); + CHECK(!sourceBypassesBoundsChannel(SourceMode::TimeSelection)); + CHECK(!sourceBypassesBoundsChannel(SourceMode::SelectedTracks)); + CHECK(!sourceBypassesBoundsChannel(SourceMode::Realtime)); +} + static void testRefusalMessagesAreSiblingsWithDistinctExits() { const std::string item = multiTrackRefusalMessage(CaptureScope::Item); const std::string track = multiTrackRefusalMessage(CaptureScope::Track); @@ -448,6 +528,10 @@ int main() { testAutoTrimRatioDerivesFromDb(); testTailManualFixedNoTrim(); testTailManualClampsToCap(); + testEachChannelNamesItsOwnBoundsFlagMode(); + testTailBitFollowsTheBoundsChannel(); + testNoneSetsNoTailBitOnEitherChannel(); + testTheChannelLabelNamesTheModeAndItsStore(); testRealtimeWindowNoneIsExact(); testRealtimeWindowAutoAddsCap(); testRealtimeWindowManualAddsClampedLength(); @@ -459,6 +543,7 @@ int main() { testScopeSourceModes(); testRangedItemScopeRendersTimeBounded(); testMultiTrackStemRenderIsNamedForRefusal(); + testSourceBypassesBoundsChannelOnlyForContentDerivedSources(); testRefusalMessagesAreSiblingsWithDistinctExits(); testRefusalMessagesMatchGoldenLiterals(); testRangeInference(); diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 9e0f0b6..184fe18 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -315,6 +315,297 @@ static void testASubMillisecondStartWouldNotHideItself() { frameCountFor(1.000, end, 48000))); } +static void testTheTwoLiveShortRendersPinnedAtFullPrecision() { + // 1.6551724137931001 is the console's own %.17g read-back. 4.0677966101694913 is + // the double nearest the six-decimal value (4.067797) the earlier refusal actually + // printed -- that refusal predates the %.17g printer (git history has no commit + // introducing this literal as a console value), so it is a reconstruction, not a + // captured one. 240/145 and 240/59 (testTheSixDecimalDisplayDidNotCreateTheEffect) + // produce the SAME counts as the literals here, so this test cannot distinguish the + // real value from the reconstruction either -- it pins the count regression (full + // precision or six-decimal input, the frame counts agree), not which double REAPER + // was really handed. + CHECK(frameCountFor(0.0, 1.6551724137931001, 48000) == 79448); + CHECK(msFlooredEndFrameCount(0.0, 1.6551724137931001, 48000) == 79440); + + CHECK(frameCountFor(0.0, 4.0677966101694913, 48000) == 195254); + CHECK(msFlooredEndFrameCount(0.0, 4.0677966101694913, 48000) == 195216); + + // And the counts REAPER produced are outside the gate's tolerance in both cases — + // the refusals were correct, not an artifact of the one-frame slack. + CHECK(!renderHonoredBounds(79448, 79440)); + CHECK(!renderHonoredBounds(195254, 195216)); +} + +// --- isOnMillisecondGrid: whether an observation can speak to an edge ---------- + +static void testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap() { + CHECK(isOnMillisecondGrid(0.0)); + CHECK(isOnMillisecondGrid(2.0)); + CHECK(isOnMillisecondGrid(0.001)); + // 1.007 s does not multiply to exactly 1007.0 in double (pinned as the premise in + // testWindowAlreadyOnTheMillisecondGridLosesNothing) and must still read as on-grid. + CHECK(isOnMillisecondGrid(1.007)); + // A whole millisecond at 44.1 kHz is 44.1 frames — off the frame grid, on this one. + CHECK(isOnMillisecondGrid(0.010)); +} + +static void testOffGridRecognizesASubMillisecondRemainder() { + CHECK(!isOnMillisecondGrid(1.6551724137931001)); + CHECK(!isOnMillisecondGrid(1.0001724)); + // One frame short of a whole second at 48 kHz is ~0.0208 ms off the grid — the + // tightest remainder this predicate has to keep seeing. + CHECK(!isOnMillisecondGrid(1.0 - 1.0 / 48000.0)); +} + +// --- describeBoundsExperiment: the console verdict on a bounds channel -------- + +static void testAnExactRenderReadsExactAndNamesItsChannel() { + const std::string s = + describeBoundsExperiment("time selection (RENDER_BOUNDSFLAG=2)", + 0.0, 1.6551724137931001, 79448, 48000); + CHECK(contains(s, "EXACT")); + CHECK(!contains(s, "SHORT")); + CHECK(contains(s, "time selection (RENDER_BOUNDSFLAG=2)")); + CHECK(contains(s, "79448")); + CHECK(contains(s, "48000 Hz")); + // The END here carries a sub-millisecond remainder, so this run DID test it -- + // the END-untested caveat must not fire on a window it didn't apply to. + CHECK(!contains(s, "END edge is UNTESTED")); +} + +static void testTheLiveShortfallReadsShortAndNamesTheMillisecondShape() { + // The observation, replayed through the verdict: 79440 produced against 79448. + const std::string s = + describeBoundsExperiment("custom time bounds (RENDER_BOUNDSFLAG=0)", + 0.0, 1.6551724137931001, 79440, 48000); + CHECK(contains(s, "SHORT")); + CHECK(!contains(s, "EXACT")); + CHECK(contains(s, "79440")); + CHECK(contains(s, "79448")); + // 79440 IS the ms-floored count, so the verdict has to say the floor did not move. + CHECK(contains(s, "floored to the millisecond")); +} + +static void testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt() { + // A render 3 frames short is short, but 79445 is not the floored count — the + // millisecond sentence must not appear, or it would assert a shape that is absent. + CHECK(msFlooredEndFrameCount(0.0, 1.6551724137931001, 48000) != 79445); + const std::string s = + describeBoundsExperiment("custom time bounds", 0.0, 1.6551724137931001, + 79445, 48000); + CHECK(contains(s, "SHORT")); + CHECK(!contains(s, "floored to the millisecond")); +} + +static void testARenderPastTheWindowReadsLong() { + // The whole-item widening, through the verdict: 30 s printed for a 1 s window. + const std::string s = + describeBoundsExperiment("custom time bounds", 5.0, 6.0, 30 * 48000, 48000); + CHECK(contains(s, "LONG")); + CHECK(contains(s, "1440000 frames")); + CHECK(contains(s, "the 48000 the window asks for")); +} + +static void testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch() { + // A window whose end is a whole millisecond has nothing for a floor to take: the + // exact count and the floored count are the same number, so an exact render reads + // EXACT and the millisecond sentence never fires. + CHECK(frameCountFor(0.0, 2.0, 48000) == msFlooredEndFrameCount(0.0, 2.0, 48000)); + const std::string s = + describeBoundsExperiment("time selection", 0.0, 2.0, 96000, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "96000")); + CHECK(!contains(s, "floored to the millisecond")); + // The false positive this window is the shape of: a render that floored either edge + // alone, or both together, would have printed this identical EXACT count (every + // edge here is on the grid) -- the line has to say this run cannot rule any of them + // out rather than reading EXACT as settled. + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors the START edge alone")); + CHECK(contains(s, "floors the END edge alone")); + CHECK(contains(s, "floors START and END together")); +} + +static void testEqualRemaindersCancelUnderAFullFloorEvenOffGrid() { + // C1: a dragged, fixed-length time selection reproduces this. Neither edge sits on + // the millisecond grid (isOnMillisecondGrid is false for both), but the START and + // END frame-rounding remainders are EQUAL (rs == re == 8 frames), so a render that + // floors both edges together lands on the identical count -- the grid predicate on + // either edge alone would have missed this collision entirely. + const double start = 1.0001724, end = 2.0001724; + CHECK(!isOnMillisecondGrid(start)); + CHECK(!isOnMillisecondGrid(end)); + const long long expected = frameCountFor(start, end, 48000); + CHECK(expected == 48000); + // The both-edges-floored render lands on the SAME count as the exact one. + CHECK(frameCountFor(1.000, 2.000, 48000) == expected); + // Neither edge floored ALONE reproduces it -- only the combined floor does. + CHECK(frameCountFor(1.000, end, 48000) != expected); + CHECK(frameCountFor(start, 2.000, 48000) != expected); + + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors START and END together")); + CHECK(!contains(s, "floors the START edge alone")); + CHECK(!contains(s, "floors the END edge alone")); +} + +static void testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd() { + // C1's second live shape: isOnMillisecondGrid reads this END as off-grid, but the + // remainder is under half a frame at 48 kHz, so flooring it doesn't move its frame + // index -- a grid test on the edge alone would still miss this collision. + const double start = 0.0, end = 1.000005; + CHECK(!isOnMillisecondGrid(end)); + const long long expected = frameCountFor(start, end, 48000); + CHECK(expected == 48000); + CHECK(frameCountFor(start, 1.000, 48000) == expected); // the floored-end model matches + + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors the END edge alone")); +} + +static void testALongVerdictNeverCarriesTheFloorSentence() { + // A floor only removes frames, so LONG can never be its signature -- the sentence + // must not appear even though the delta here is a "clean" one-frame LONG. + const std::string s = + describeBoundsExperiment("time selection", 5.0, 6.0, 48001, 48000); + CHECK(contains(s, "LONG")); + CHECK(!contains(s, "floored to the millisecond")); +} + +static void testASubFrameWindowIsNotJudgedNotExact() { + // A window under one frame at this rate rounds to 0 expected frames. A 0-frame + // render against that is a 0-vs-0 coincidence of degenerate inputs, not a match -- + // it must read NOT JUDGED, never EXACT. + const double oneTenthOfAFrame = 1.0 / (48000.0 * 10.0); + const long long expected = frameCountFor(0.0, oneTenthOfAFrame, 48000); + CHECK(expected == 0); + const std::string s = + describeBoundsExperiment("time selection", 0.0, oneTenthOfAFrame, 0, 48000); + CHECK(contains(s, "NOT JUDGED")); + CHECK(!contains(s, "EXACT")); +} + +static void testAWithinToleranceDeltaIsTaggedNotFloorShaped() { + // One frame off frameCountFor is the gate's own edge-convention slack + // (render_window.h), not the millisecond floor -- the verdict must say so rather + // than reading like a genuine miss or like the floor was escaped. + const std::string shortByOne = + describeBoundsExperiment("time selection", 0.0, 4.067797, 195253, 48000); + CHECK(contains(shortByOne, "SHORT")); + CHECK(contains(shortByOne, "WITHIN TOLERANCE")); + CHECK(!contains(shortByOne, "floored to the millisecond")); + + const std::string longByOne = + describeBoundsExperiment("time selection", 0.0, 4.067797, 195255, 48000); + CHECK(contains(longByOne, "LONG")); + CHECK(contains(longByOne, "WITHIN TOLERANCE")); + + // A genuine miss (outside the tolerance) carries no such tag. + const std::string shortByThree = + describeBoundsExperiment("time selection", 0.0, 4.067797, 195251, 48000); + CHECK(contains(shortByThree, "SHORT")); + CHECK(!contains(shortByThree, "WITHIN TOLERANCE")); +} + +static void testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel() { + // SelectedItems/RazorArea derive their own bounds from content -- the channel + // named by channelLabel was never consulted, so a matching frame count here would + // be a coincidence, not evidence the channel escaped the floor. + const std::string s = + describeBoundsExperiment("time selection", 0.0, 1.6551724137931001, + 79448, 48000, "selected media items"); + CHECK(contains(s, "NOT JUDGED")); + CHECK(contains(s, "selected media items")); + CHECK(!contains(s, "EXACT")); + // The channel is still named at the top of the line -- only the verdict changes. + CHECK(contains(s, "time selection")); +} + +static void testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict() { + // Off-grid, non-cancelling edges (see testEqualRemaindersCancelUnderAFullFloorEvenOffGrid + // for the window shape that WOULD trip the collision caveat, whose own text also + // contains "EXACT") so this assertion is pinned to the verdict word itself, not to a + // caveat sentence that happens to contain the same substring. + const double start = 1.0001724, end = 2.0009724; + const long long expected = frameCountFor(start, end, 48000); + const std::string withNull = + describeBoundsExperiment("time selection", start, end, expected, 48000, nullptr); + CHECK(contains(withNull, "EXACT")); + CHECK(!contains(withNull, "EXACT here is not proof")); + CHECK(contains(describeBoundsExperiment("time selection", start, end, expected, 48000, + ""), + "EXACT")); +} + +static void testAnOnGridStartSaysTheStartEdgeIsUntested() { + // Both live observations started at 0 s — the value that hides a start-side floor. + const std::string s = + describeBoundsExperiment("time selection", 0.0, 1.6551724137931001, 79448, 48000); + CHECK(contains(s, "UNTESTED")); + CHECK(contains(s, "millisecond grid")); +} + +static void testAnOffGridStartSaysTheStartEdgeIsTested() { + // The run that would genuinely settle the start question: a start carrying its own + // remainder, paired with an end whose remainder does NOT cancel it (unlike + // testEqualRemaindersCancelUnderAFullFloorEvenOffGrid's window, where the same shape + // of start value pairs with an end that cancels it and the collision caveat fires + // instead). No floored model reproduces this count, so EXACT here is unqualified. + const double start = 1.0001724, end = 2.0009724; + const long long expected = frameCountFor(start, end, 48000); + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "IS tested")); + CHECK(!contains(s, "UNTESTED")); + CHECK(contains(s, "EXACT")); + CHECK(!contains(s, "EXACT here is not proof")); + // A start-floored-alone render would have printed a DIFFERENT count here, so a + // mismatch against `expected` on a re-run is real evidence, not ambiguous. + CHECK(frameCountFor(1.000, end, 48000) != expected); + CHECK(contains(describeBoundsExperiment("time selection", start, end, + frameCountFor(1.000, end, 48000), 48000), + "LONG")); +} + +static void testAt44100WhereAMillisecondIsNotAWholeNumberOfFrames() { + // 44.1 kHz: the window is 463 frames, the ms-floored one 441 (both pinned in + // testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames). The verdict has to + // reach the same two numbers at a rate where a millisecond is 44.1 frames. + const std::string exact = + describeBoundsExperiment("time selection", 0.0, 0.0105, 463, 44100); + CHECK(contains(exact, "EXACT")); + CHECK(contains(exact, "44100 Hz")); + + const std::string floored = + describeBoundsExperiment("custom time bounds", 0.0, 0.0105, 441, 44100); + CHECK(contains(floored, "SHORT")); + CHECK(contains(floored, "floored to the millisecond")); +} + +static void testAnUnmeasuredRenderAnswersNothingRatherThanPassing() { + // Auto/Manual are not judged against a frame count, and an empty render has none. + // The line must still print and must not read as a pass — its silence would. + const std::string s = + describeBoundsExperiment("time selection", 0.0, 1.6551724137931001, 0, 0); + CHECK(!s.empty()); + CHECK(contains(s, "NOT JUDGED")); + CHECK(!contains(s, "EXACT")); + CHECK(contains(s, "time selection")); +} + +static void testAnUnnamedChannelStillProducesAReadableLine() { + CHECK(contains(describeBoundsExperiment(nullptr, 0.0, 1.0, 48000, 48000), + "unnamed")); + CHECK(contains(describeBoundsExperiment("", 0.0, 1.0, 48000, 48000), "unnamed")); +} + // --- describeBoundsDrift: the read-back's verdict ------------------------------ static void testBoundsThatReadBackUnchangedDescribeNothing() { @@ -395,6 +686,26 @@ int main() { testOneFrameOfRemainderStillFloors(); testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames(); testASubMillisecondStartWouldNotHideItself(); + testTheTwoLiveShortRendersPinnedAtFullPrecision(); + testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap(); + testOffGridRecognizesASubMillisecondRemainder(); + testAnExactRenderReadsExactAndNamesItsChannel(); + testTheLiveShortfallReadsShortAndNamesTheMillisecondShape(); + testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt(); + testARenderPastTheWindowReadsLong(); + testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch(); + testEqualRemaindersCancelUnderAFullFloorEvenOffGrid(); + testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd(); + testALongVerdictNeverCarriesTheFloorSentence(); + testASubFrameWindowIsNotJudgedNotExact(); + testAWithinToleranceDeltaIsTaggedNotFloorShaped(); + testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel(); + testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict(); + testAnOnGridStartSaysTheStartEdgeIsUntested(); + testAnOffGridStartSaysTheStartEdgeIsTested(); + testAt44100WhereAMillisecondIsNotAWholeNumberOfFrames(); + testAnUnmeasuredRenderAnswersNothingRatherThanPassing(); + testAnUnnamedChannelStillProducesAReadableLine(); testBoundsThatReadBackUnchangedDescribeNothing(); testADriftedEndNamesBothWindowsAndBothCounts(); testTheReportPrintsEnoughDigitsToShowTheDrift();