From 8331df2e919714e6fa62b81163e73fcce535d5a2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 16:55:12 -0400 Subject: [PATCH 1/2] Settle the render window on the time selection and delete the experiment that proved it The millisecond floor lives in the custom-bounds field, not the engine, so RENDER_BOUNDSFLAG=2 is now the only bounds mode: the two-position type, the console verdict and the STARTPOS/ENDPOS drift probe all go. capture.cpp 697 -> 622. --- docs/TODO.md | 30 +- docs/VERIFICATION.md | 10 +- src/core/capture/CLAUDE.md | 15 +- src/core/capture/render_settings.cpp | 39 +- src/core/capture/render_settings.h | 85 ++--- src/core/capture/render_window.cpp | 130 ------- src/core/capture/render_window.h | 88 +---- src/shell/capture/CLAUDE.md | 12 +- src/shell/capture/capture.cpp | 142 ++------ src/shell/capture/capture_orchestrator.cpp | 4 +- src/shell/capture/render_bounds_gate.cpp | 2 - src/shell/capture/render_bounds_gate.h | 7 - tests/test_render_settings.cpp | 110 ++---- tests/test_render_window.cpp | 393 ++++----------------- 14 files changed, 205 insertions(+), 862 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 515703a..df69f65 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -765,20 +765,26 @@ these refusals — but it means a future one- or two-frame refusal may be ours, the tolerance was not widened on speculation. Widening it is a precision-invariant decision, not a bug fix. -## `capture.cpp` is over the ~600-line ceiling — documented, not split mid-experiment +## `capture.cpp` is over the ~600-line ceiling — the seam is identified, taking it is blocked -**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`). +**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 +seam that entry originally named is gone with the instrumentation; nothing left in the +file is bisectable without cutting load-bearing why. -**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. +**The remaining seam is a real responsibility boundary**, and the file header already +names it as two things: `OfflineRenderBackend::capture` (the offline render driver) +versus the four helpers BOTH backends share — `makeUniqueTag`, `captureNameFor`, +`collapseCapturedFileToMono`, `stampCaptureSample` — consumed by `capture_batch`, +`capture_orchestrator`, `capture_realtime_shell`, `capture_realtime_finalize` and +`render_in_place`. Lifting those four into their own TU takes the driver under the +ceiling and gives the cross-backend steps their own home. + +**Why not taken.** `src/shell/capture/` has no `CMakeLists.txt` of its own — its sources +are listed in `src/app/CMakeLists.txt`, so a new TU needs an edit there. Forcing the +four helpers into an existing TU instead (orchestrator, realtime finalize) would put +them in a wrong home to dodge one build-file line, which is worse than the overshoot. ## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated) diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 6b6f31d..c6f86b9 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -26,11 +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`) -- [ ] **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 +- [ ] **The millisecond floor — SETTLED, nothing to re-run for `TailMode::None`.** The floor lives in the custom-time-bounds field (`RENDER_BOUNDSFLAG=0`), not in the render engine. Two live 48 kHz `TailMode::None` renders on `RENDER_BOUNDSFLAG=2` (time selection, handed over via `GetSet_LoopTimeRange`) came back exact — 97627 frames against 97627 — the second over a window whose START carried a sub-millisecond remainder, with no floored model of that window able to reproduce the count. Time selection is now the only bounds mode a capture can reach; the console verdict line and the `RENDER_STARTPOS`/`ENDPOS` read-back probe that answered this are gone. Full observation: `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection` +- [ ] **Still open — Auto and Manual tail.** `checkRenderedBounds` judges `TailMode::None` only (Auto/Manual add frames by design), so the settled result covers those two by INFERENCE, not observation: the floor applied to the bounds identically on all three tail modes, and all three now hand the window over the same way. What would establish it: repeat an off-grid-start capture at **Manual** over a source that is loud right to the window's end, and check the landed file's frames against window + `tailMs` — a floored edge shows up in that count. **Auto** cannot be checked by count (it trims trailing silence), so it needs the null test by ear/inversion against the source instead +- [ ] `[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, a tail capture is silently SHORTER than its window with no detector at all. Report whether either tail capture comes up short against the source +- [ ] A refused render is 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 +- [ ] **If a capture is refused for a short render**, report the refusal line verbatim. A message naming `floored to the millisecond` means the floor is back on a mode measured escaping it; a shortfall of one or two frames with no such sentence may be the gate's own edge-convention tolerance rather than the render (`render_window.h`'s `renderHonoredBounds`) ## Names and channels diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 88abdc6..2f0e2fa 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 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). +- `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 one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), 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 one short-render diagnostic: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched on the retired custom-bounds mode, quoted by a refusal as a count coincidence and nothing more) and `isOnMillisecondGrid`, the whole-millisecond tolerance that count depends on. - `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,12 +79,11 @@ 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. +- **The custom-time-bounds field floors the render window to the millisecond; the + time selection does not.** Both observations and why only one bounds mode is + reachable: `render_settings.h`'s `kRenderBoundsTimeSelection` — the one narrative + home; this bullet is a pointer, not a retelling. Do not reintroduce + `RENDER_BOUNDSFLAG=0`. - `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 83ca559..1d72782 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -14,36 +14,7 @@ double autoTrimEndRatio() { return std::pow(10.0, kAutoTrimThresholdDb / 20.0); } -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 tailRenderSettingsFor(TailMode mode, double manualTailMs) { TailRenderSettings t; switch (mode) { case TailMode::None: @@ -59,7 +30,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 = tailFlagBitFor(channel); + t.tailFlag = kTailFlagTimeSelection; t.tailMs = kMaxTailMs; t.normalize = kNormalizeTrimEnd; t.trimEnd = autoTrimEndRatio(); @@ -67,7 +38,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs, case TailMode::Manual: // Clamped to the cap regardless of source; negative floors to 0. - t.tailFlag = tailFlagBitFor(channel); + t.tailFlag = kTailFlagTimeSelection; t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.normalize = kNormalizeDisableAll; t.trimEnd = 0.0; @@ -143,10 +114,6 @@ 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 cc04cfc..3fdb844 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -27,51 +27,34 @@ 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 ---------------------------------------------------- +// --- Render bounds mode ------------------------------------------------------- // -// 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); +// A capture hands its window over on RENDER_BOUNDSFLAG=2 — the project's own TIME +// SELECTION (value verbatim, header ~3042), written through GetSet_LoopTimeRange. +// +// Custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS, header +// ~3045-3046) must NOT be reintroduced: REAPER resolved a custom-bounds window on a +// whole-millisecond grid AT RENDER TIME, floored the end, wrote the floored value back +// over RENDER_ENDPOS, and rendered exactly the floored frame count — twice, to the +// frame. Re-rendering on this mode came back exact on both edges, including a start +// carrying a sub-millisecond remainder, which is what locates the floor in the +// custom-bounds field rather than downstream in the render engine. This is the one +// narrative home for that; other sites point here. +inline constexpr int kRenderBoundsTimeSelection = 2; // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // -// 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). +// 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 kTailFlagTimeSelection = 4; // &4, header ~3047 +inline constexpr int kTailFlagNone = 0; -// The RENDER_TAILFLAG bit that applies to a channel's bounds mode. -int tailFlagBitFor(RenderBoundsChannel channel); +// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047): &4 is the +// time-selection mode's bit, the pair of kRenderBoundsTimeSelection above. A tail set +// under a different mode's bit renders no tail at all, so these two move together. +inline constexpr int kTailFlagTimeSelection = 4; // Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND // ratio derives from this dB, never the reverse). Daniel-set. @@ -99,18 +82,15 @@ enum class TailMode { // normalize bit is set (Auto). The backend reads these straight onto // GetSetProjectInfo. struct TailRenderSettings { - int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the channel's bit) + int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the bounds mode'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. `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); +// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. +TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); // 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 @@ -137,26 +117,13 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); // bounds refusal: the two ways a render can miss its window — a source that // derives its own bounds (selected items, razor edits) versus a time-bounded // render that came up short — are indistinguishable from a frame count alone, -// and naming the source is what tells them apart in a bug report. Quoted verbatim -// in docs/VERIFICATION.md, which asks for this exact line back. +// and naming the source is what tells them apart in a bug report. // // MasterMix and TimeSelection deliberately answer the SAME words: they map to the -// same RENDER_SETTINGS value and every capture renders custom-time-bounded, so -// naming them apart would assert a render distinction that does not exist. +// same RENDER_SETTINGS value and render identically, so 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 8e92b36..82ec244 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -3,7 +3,6 @@ #include "core/capture/render_window.h" #include -#include namespace reasampler::capture { @@ -23,14 +22,6 @@ double floorToMilliseconds(double seconds) { return std::floor(ms) / 1000.0; } -// Full round-trip precision: a drift report whose two numbers print identically -// would be evidence of nothing. -std::string exactly(double seconds) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.17g", seconds); - return buf; -} - } // namespace long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) { @@ -67,125 +58,4 @@ long long msFlooredEndFrameCount(double startSeconds, double endSeconds, 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) { - // Bit equality, deliberately: the caller wrote these exact doubles and read them - // straight back, so anything but the same bits is a value REAPER changed. - if (storedStart == reqStart && storedEnd == reqEnd) return {}; - - // Says only that the two differ, not why -- a legitimate clamp (negative start, - // end past project end) reads back differently for the same reason a precision - // defect would, and this sentence cannot tell those apart. - std::string s = "REAPER read back different render bounds than it was handed -- " - "asked for [" + - exactly(reqStart) + "s, " + exactly(reqEnd) + "s), read back [" + - exactly(storedStart) + "s, " + exactly(storedEnd) + "s)."; - if (sampleRate > 0) { - s += " The stored window is " + - std::to_string(frameCountFor(storedStart, storedEnd, sampleRate)) + - " frames against the " + - std::to_string(frameCountFor(reqStart, reqEnd, sampleRate)) + - " the request asks for, at " + std::to_string(sampleRate) + " Hz."; - } - return s; -} - } // namespace reasampler::capture diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index 5e64d90..c736bee 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -1,14 +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 -// 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. +// 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 one diagnostic a +// refused render quotes — whether its shortfall matches a millisecond-floor coincidence. // NO REAPER types; unit-tested by tests/test_render_window.cpp. -#include - namespace reasampler::capture { // Frames the [startSeconds, endSeconds) window occupies at `sampleRate`. Both @@ -30,8 +26,7 @@ long long frameCountFor(double startSeconds, double endSeconds, int sampleRate); // end edge by DIFFERENT conventions can legitimately sit TWO frames from this answer // (tests/test_render_window.cpp pins both facts). Which model REAPER uses is // unverified, so a refusal one or two frames wide may be this gate's fault rather than -// the render's — the open DAW question in docs/VERIFICATION.md §Capture range and -// bounds. Widening past one frame retires the exact-bounds invariant rather than +// the render's. Widening past one frame retires the exact-bounds invariant rather than // relaxing it, and is not a fix to reach for before that question is answered. bool renderHonoredBounds(long long expectedFrames, long long actualFrames); @@ -50,9 +45,11 @@ 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. 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. +// grid, floored, instead of exactly. That is what REAPER's offline render did on the +// retired custom-time-bounds mode (render_settings.h's kRenderBoundsTimeSelection states +// the whole observation): two live short renders (48 kHz, TailMode::None) printed this +// count to the frame. Kept as the refusal's shape check — a refused render matching it +// says the floor is back, on a mode that was measured escaping it. // // 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 @@ -64,70 +61,15 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, // // 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 +// floored render would then read as an unmatched short render 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. +// True when `seconds` sits on a whole-millisecond boundary, under the nanosecond +// tolerance msFlooredEndFrameCount depends on and for the reason stated there. Public so +// that premise is testable directly rather than only through the count it feeds. 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 -// bit-identical, which is the only answer proving the request crossed into REAPER -// intact; a caller prints this only when it is non-empty. -std::string describeBoundsDrift(double reqStart, double reqEnd, - double storedStart, double storedEnd, - int sampleRate); - } // namespace reasampler::capture diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 622e013..c953d69 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -36,14 +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. The bounds channel is inside the backend + miss `RunRecaptureFromSource` entirely. The bounds mode 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`. +- **The render window travels in the project's own TIME SELECTION** + (`RENDER_BOUNDSFLAG=2`), so `capture` snapshots and restores that selection on every + exit path like any other state it borrows. The custom-bounds field floors the window + to the millisecond and must not come back — why, in + `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`. - **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 2191a55..6ba7b73 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -6,8 +6,8 @@ // 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) plus, on the -// time-selection bounds channel, the project time selection; snapshots and restores +// selection bits come from the pure render_settings mapping) plus the project time +// selection, which is where the render window itself travels; 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 @@ -35,7 +35,6 @@ #include "core/capture/wav_codec.h" // hashWavContent / collapseToMono — the one WAV/RIFF owner #include "core/util/file_bytes.h" #include "core/capture/render_settings.h" -#include "core/capture/render_window.h" // describeBoundsDrift — the read-back's verdict #include "shell/capture/render_bounds_gate.h" // the exact-bounds verdict on the landed render #define REAPERAPI_MINIMAL @@ -63,14 +62,6 @@ namespace { // project — why we set them all explicitly first. constexpr int kActionRenderUsingMostRecentSettings = 42230; -// 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. @@ -182,21 +173,17 @@ 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. +// The project time selection, snapshotted and restored around the render that carries +// its window in it. Separate from ScopedRenderSettings because it is project state +// rather than a RENDER_* setting. // 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() { 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; @@ -469,46 +456,35 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { } ScopedRenderSettings guard(proj); - ScopedTimeSelection tsGuard(kUsesTimeSelectionBounds); + ScopedTimeSelection tsGuard; - // 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. + // The window travels in the project's own time selection, which is what makes the + // rendered length the requested range with NO rounding and NO added silence (unless + // a tail was explicitly requested) — the custom-bounds field floors it to the + // millisecond (render_settings.h's kRenderBoundsTimeSelection). + // + // RENDER_STARTPOS/ENDPOS are written anyway, to the same window. The header + // (~3045-3046) documents them as mode-0-only, but the DAW run that settled this + // channel had both stores holding the identical window, so it cannot distinguish + // "mode 2 ignored them" from "mode 2 read them and they happened to agree". Writing + // them keeps the two stores agreeing rather than resting exactness on that + // distinction; a stale leftover here could only ever misalign a render silently. GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", - static_cast(renderBoundsFlagFor(kBoundsChannel)), true); + static_cast(kRenderBoundsTimeSelection), 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 // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no // 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, kBoundsChannel); + tailRenderSettingsFor(request.tailMode, request.tailMs); GetSetProjectInfo(proj, "RENDER_TAILFLAG", static_cast(tail.tailFlag), true); GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true); @@ -532,33 +508,6 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { static_cast(effectiveSampleRate), true); } - // Silent unless a bound came back changed. Fires on EVERY tail mode on purpose: - // only None is judged against its window after the render, so this is the sole - // signal an Auto/Manual capture was shortened before it ever started. Named by - // checkpoint so a DAW observation is self-locating: three reads bracket the two - // places REAPER could quantize — the store, and the render itself. - auto reportDrift = [&](const char* checkpoint, double atStart, double atEnd) { - const std::string drift = - describeBoundsDrift(request.startSeconds, request.endSeconds, - atStart, atEnd, effectiveSampleRate); - if (!drift.empty()) - ShowConsoleMsg(("ReaSampler capture (" + std::string(checkpoint) + "): " + - drift + "\n").c_str()); - }; - - // 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); @@ -580,37 +529,10 @@ 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)."; @@ -618,32 +540,12 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { } setProjString(proj, "RENDER_FORMAT", fmtBase64); - // 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. 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, 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."; @@ -657,7 +559,6 @@ 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; @@ -671,7 +572,6 @@ 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 edf0a5c..a59ecdd 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -231,8 +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 it changed plus, on the -// time-selection bounds channel, the project time selection it borrowed. +// and the backend restores every RENDER_* setting it changed plus the project time +// selection it borrowed to carry the render window. // // 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 158a9d5..97b7bce 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -117,8 +117,6 @@ 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 known shape: the END alone floored to the diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index a82b48c..55a3433 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -18,13 +18,6 @@ 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 f048265..1ae1b3c 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -111,12 +111,8 @@ 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); + return tailRenderSettingsFor(mode, manualTailMs); } static void testTailNoneIsExactBounds() { @@ -136,11 +132,11 @@ static void testTailNoneIsExactBounds() { } 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). + // Auto -> the time-selection 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 catches a regression to the None normalize). TailRenderSettings t = tailFor(TailMode::Auto, 0.0); - CHECK(t.tailFlag == kTailFlagCustomBounds); // &1 + CHECK(t.tailFlag == kTailFlagTimeSelection); // &4 CHECK(t.tailMs == kMaxTailMs); // 8000 CHECK(t.normalize == kNormalizeTrimEnd); // exactly 32768, nothing else CHECK((t.normalize & kNormalizeDisableAll) == 0); // disable-all is NOT set @@ -161,11 +157,11 @@ static void testAutoTrimRatioDerivesFromDb() { } static void testTailManualFixedNoTrim() { - // Manual -> custom-bounds tail, the requested ms (within cap), disable-all + // Manual -> the time-selection tail bit, 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 = tailFor(TailMode::Manual, 2500.0); - CHECK(t.tailFlag == kTailFlagCustomBounds); + CHECK(t.tailFlag == kTailFlagTimeSelection); CHECK(t.tailMs == 2500.0); CHECK(t.normalize == kNormalizeDisableAll); CHECK(t.trimEnd == 0.0); @@ -181,59 +177,29 @@ static void testTailManualClampsToCap() { CHECK(tailFor(TailMode::Manual, -50.0).tailMs == 0.0); } -// --- bounds channel: RENDER_BOUNDSFLAG mode + the tail bit it drags along ------ +// --- bounds mode: RENDER_BOUNDSFLAG + the tail bit paired with it --------------- -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 testTheBoundsModeIsTheTimeSelectionAndItsTailBitIsPairedWithIt() { + // Literals from the SDK header, pinned as numbers so neither can drift onto + // another bounds mode's value: RENDER_BOUNDSFLAG 2 = time selection (~3042), and + // RENDER_TAILFLAG's bits are keyed per bounds mode, &4 = time selection (~3047). + // The custom-bounds pair (0 / &1) is DELIBERATELY absent — that mode floors the + // window to the millisecond (render_settings.h) and must not come back. + CHECK(kRenderBoundsTimeSelection == 2); + CHECK(kTailFlagTimeSelection == 4); + CHECK(kTailFlagNone == 0); } -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); +static void testEveryTailModeSetsTheBitTheBoundsModeReads() { + // A tail set under a different bounds mode's bit renders no tail at all, so both + // tail-bearing modes must carry &4 — a fix applied to Auto alone would leave + // Manual silently tailless. + for (TailMode mode : {TailMode::Auto, TailMode::Manual}) + CHECK(tailRenderSettingsFor(mode, 2500.0).tailFlag == kTailFlagTimeSelection); - // 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); + // None is exact bounds: no tail bit at all, whatever ms it is handed. + CHECK(tailRenderSettingsFor(TailMode::None, 5000.0).tailFlag == kTailFlagNone); + CHECK(tailRenderSettingsFor(TailMode::None, 0.0).tailFlag == kTailFlagNone); } // --- realtimeRecordWindowEnd: the T2 record-window extension ----------------- @@ -384,23 +350,6 @@ 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); @@ -528,10 +477,8 @@ int main() { testAutoTrimRatioDerivesFromDb(); testTailManualFixedNoTrim(); testTailManualClampsToCap(); - testEachChannelNamesItsOwnBoundsFlagMode(); - testTailBitFollowsTheBoundsChannel(); - testNoneSetsNoTailBitOnEitherChannel(); - testTheChannelLabelNamesTheModeAndItsStore(); + testTheBoundsModeIsTheTimeSelectionAndItsTailBitIsPairedWithIt(); + testEveryTailModeSetsTheBitTheBoundsModeReads(); testRealtimeWindowNoneIsExact(); testRealtimeWindowAutoAddsCap(); testRealtimeWindowManualAddsClampedLength(); @@ -543,7 +490,6 @@ int main() { testScopeSourceModes(); testRangedItemScopeRendersTimeBounded(); testMultiTrackStemRenderIsNamedForRefusal(); - testSourceBypassesBoundsChannelOnlyForContentDerivedSources(); testRefusalMessagesAreSiblingsWithDistinctExits(); testRefusalMessagesMatchGoldenLiterals(); testRangeInference(); diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 184fe18..7c2f830 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -2,13 +2,12 @@ // Covers the bounds-equality number (a window's exact frame count at the project // rate), the verdict the offline backend refuses a capture on, the predicate // that decides whether REAPER's selected-items render source can express a -// requested window at all, and the two short-render diagnostics. +// requested window at all, and the millisecond-floor shape a refusal quotes. #include "../src/core/capture/render_window.h" #include #include -#include using namespace reasampler::capture; @@ -16,10 +15,6 @@ static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) -static bool contains(const std::string& haystack, const std::string& needle) { - return haystack.find(needle) != std::string::npos; -} - // --- frameCountFor: the bounds equality, stated as a number ------------------ static void testFrameCountIsExactNotRounded() { @@ -358,308 +353,86 @@ static void testOffGridRecognizesASubMillisecondRemainder() { CHECK(!isOnMillisecondGrid(1.0 - 1.0 / 48000.0)); } -// --- describeBoundsExperiment: the console verdict on a bounds channel -------- +// --- the settled time-selection observations, as pure arithmetic --------------- +// +// Two live 48 kHz TailMode::None renders on RENDER_BOUNDSFLAG=2 came back EXACT at +// 97627 frames. The console printed run TWO's start verbatim (2.0338983050847457s); +// run ONE started at 0s and its end was never printed, so the value below is a +// reconstruction from run two's own printed start — it pins the count, not which +// double REAPER was handed. -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 testTheSettledExactRenderOnTheOnGridStart() { + CHECK(frameCountFor(0.0, 2.0338983050847457, 48000) == 97627); + // Run one could not test the START: 0s is on the grid, which floor, ceil and round + // all leave alone, so a start-flooring render prints the identical count. + CHECK(isOnMillisecondGrid(0.0)); + // Its END, though, WAS under test — a floored end would have printed 43 frames fewer. + CHECK(msFlooredEndFrameCount(0.0, 2.0338983050847457, 48000) == 97584); + CHECK(!renderHonoredBounds(97627, 97584)); } -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; +static void testTheSettledExactRenderTestedBothEdges() { + // Run two: both edges carry a sub-millisecond remainder, and the render still + // printed the window's exact count. + const double start = 2.0338983050847457, end = 4.0677966101694913; 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); + CHECK(frameCountFor(start, end, 48000) == 97627); - 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")); + // What makes that EXACT proof rather than a coincidence: NO millisecond-floored + // model of this window reproduces 97627, and every one of them sits outside the + // gate's one-frame tolerance. This is the assertion the whole experiment rests on. + const long long startAlone = frameCountFor(2.033, end, 48000); + const long long endAlone = frameCountFor(start, 4.067, 48000); + const long long bothTogether = frameCountFor(2.033, 4.067, 48000); + CHECK(startAlone == 97670); + CHECK(endAlone == 97589); + CHECK(bothTogether == 97632); + CHECK(!renderHonoredBounds(97627, startAlone)); + CHECK(!renderHonoredBounds(97627, endAlone)); + CHECK(!renderHonoredBounds(97627, bothTogether)); } -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 +static void testOnAndOffGridWindowsAreHonoredIdentically() { + // Nothing on the settled path may treat a grid-aligned window differently from one + // carrying a remainder — the whole point of leaving the flooring channel behind. + const double onStart = 1.000, onEnd = 2.000; + const double offStart = 1.0001724, offEnd = 2.0001724; + CHECK(isOnMillisecondGrid(onStart)); + CHECK(isOnMillisecondGrid(onEnd)); + CHECK(!isOnMillisecondGrid(offStart)); + CHECK(!isOnMillisecondGrid(offEnd)); - 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")); + const long long on = frameCountFor(onStart, onEnd, 48000); + const long long off = frameCountFor(offStart, offEnd, 48000); + CHECK(on == 48000); + CHECK(off == 48000); + + // The discriminating half: the off-grid window is one a flooring render WOULD get + // wrong (47992 against 48000) while the on-grid one is untouched by a floor. The + // gate's verdict must not notice that difference at any delta. + CHECK(msFlooredEndFrameCount(offStart, offEnd, 48000) == 47992); + CHECK(msFlooredEndFrameCount(onStart, onEnd, 48000) == on); + for (long long delta = -3; delta <= 3; ++delta) + CHECK(renderHonoredBounds(on, on + delta) == + renderHonoredBounds(off, off + delta)); } -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() { - // The answer that proves the request crossed into REAPER intact — including for a - // window whose end is nowhere near a millisecond boundary. - CHECK(describeBoundsDrift(0.0, 4.067797, 0.0, 4.067797, 48000).empty()); - CHECK(describeBoundsDrift(1.0001724, 2.0001724, 1.0001724, 2.0001724, 48000).empty()); -} - -static void testADriftedEndNamesBothWindowsAndBothCounts() { - const std::string s = - describeBoundsDrift(0.0, 4.067797, 0.0, 4.067, 48000); - CHECK(!s.empty()); - // Both counts as literals from the DAW observation, not re-derived from the same - // functions the sentence was built with. - CHECK(contains(s, "195254")); // what the request asks for - CHECK(contains(s, "195216")); // what the drifted window would hold - CHECK(contains(s, "48000 Hz")); -} - -static void testTheReportPrintsEnoughDigitsToShowTheDrift() { - // A report whose two numbers print identically is evidence of nothing. Two ends a - // single ULP apart — far under the sixth decimal a shorter rendering would stop at - // — must still read as two different numbers. Pinned as the actual %.17g literals - // (not the needle the two ends share, "s)", which occurs at every precision and so - // proves nothing): a report that regressed to a shorter format like %.6g would - // print the same six significant digits for both ends, and these two `contains` - // checks would then fail. - const double asked = 4.067797; - const double stored = std::nextafter(asked, 5.0); - char askedBuf[32], storedBuf[32]; - std::snprintf(askedBuf, sizeof(askedBuf), "%.17g", asked); - std::snprintf(storedBuf, sizeof(storedBuf), "%.17g", stored); - CHECK(std::string(askedBuf) != std::string(storedBuf)); - - const std::string s = describeBoundsDrift(0.0, asked, 0.0, stored, 48000); - CHECK(!s.empty()); - CHECK(contains(s, askedBuf)); - CHECK(contains(s, storedBuf)); -} - -static void testADriftedStartIsCaughtToo() { - // The edge both observations could not test. - const std::string s = describeBoundsDrift(1.0001724, 2.0, 1.000, 2.0, 48000); - CHECK(!s.empty()); - CHECK(contains(s, "1.0001724")); -} - -static void testAnUnknownRateStillReportsTheDriftWithoutFrames() { - // A project that never pinned a rate reads 0. The drift is still worth saying; a - // frame count over an unknown rate is not. - const std::string s = describeBoundsDrift(0.0, 4.067797, 0.0, 4.067, 0); - CHECK(!s.empty()); - CHECK(!contains(s, "frames")); +static void testOnAndOffGridAt44100WhereAMillisecondIsNotWholeFrames() { + // 44.1 kHz: a millisecond is 44.1 frames, so a grid-aligned window's edges are NOT + // frame-aligned. The exact counts must still be exact and the two must still be + // judged identically. + const double onStart = 1.000, onEnd = 2.000; + const double offStart = 1.0001724, offEnd = 2.0001724; + const long long on = frameCountFor(onStart, onEnd, 44100); + const long long off = frameCountFor(offStart, offEnd, 44100); + CHECK(on == 44100); + CHECK(off == 44100); + CHECK(msFlooredEndFrameCount(offStart, offEnd, 44100) == 44092); + CHECK(msFlooredEndFrameCount(onStart, onEnd, 44100) == on); + for (long long delta = -3; delta <= 3; ++delta) + CHECK(renderHonoredBounds(on, on + delta) == + renderHonoredBounds(off, off + delta)); } int main() { @@ -689,28 +462,10 @@ int main() { testTheTwoLiveShortRendersPinnedAtFullPrecision(); testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap(); testOffGridRecognizesASubMillisecondRemainder(); - testAnExactRenderReadsExactAndNamesItsChannel(); - testTheLiveShortfallReadsShortAndNamesTheMillisecondShape(); - testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt(); - testARenderPastTheWindowReadsLong(); - testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch(); - testEqualRemaindersCancelUnderAFullFloorEvenOffGrid(); - testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd(); - testALongVerdictNeverCarriesTheFloorSentence(); - testASubFrameWindowIsNotJudgedNotExact(); - testAWithinToleranceDeltaIsTaggedNotFloorShaped(); - testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel(); - testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict(); - testAnOnGridStartSaysTheStartEdgeIsUntested(); - testAnOffGridStartSaysTheStartEdgeIsTested(); - testAt44100WhereAMillisecondIsNotAWholeNumberOfFrames(); - testAnUnmeasuredRenderAnswersNothingRatherThanPassing(); - testAnUnnamedChannelStillProducesAReadableLine(); - testBoundsThatReadBackUnchangedDescribeNothing(); - testADriftedEndNamesBothWindowsAndBothCounts(); - testTheReportPrintsEnoughDigitsToShowTheDrift(); - testADriftedStartIsCaughtToo(); - testAnUnknownRateStillReportsTheDriftWithoutFrames(); + testTheSettledExactRenderOnTheOnGridStart(); + testTheSettledExactRenderTestedBothEdges(); + testOnAndOffGridWindowsAreHonoredIdentically(); + testOnAndOffGridAt44100WhereAMillisecondIsNotWholeFrames(); if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; } std::printf("render_window: all checks passed\n"); From 4c7e0507a145f7281ab3be4d10e5d84f9955daab Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 17:08:11 -0400 Subject: [PATCH 2/2] Fix vacuous bounds test and stale/circular comments from the settle Replace the self-comparing render-window loop with a genuinely discriminating floor-vs-exact check; correct two stale claims; mark the Auto/Manual floor-parity premise as unverified; drop the STARTPOS/ENDPOS comment's circular justification. --- docs/VERIFICATION.md | 2 +- src/shell/capture/capture.cpp | 8 +++----- tests/test_render_settings.cpp | 15 +++++++++------ tests/test_render_window.cpp | 32 +++++++++++++++++++------------- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index c6f86b9..02cdf28 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -27,7 +27,7 @@ Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, i - [ ] 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`) - [ ] **The millisecond floor — SETTLED, nothing to re-run for `TailMode::None`.** The floor lives in the custom-time-bounds field (`RENDER_BOUNDSFLAG=0`), not in the render engine. Two live 48 kHz `TailMode::None` renders on `RENDER_BOUNDSFLAG=2` (time selection, handed over via `GetSet_LoopTimeRange`) came back exact — 97627 frames against 97627 — the second over a window whose START carried a sub-millisecond remainder, with no floored model of that window able to reproduce the count. Time selection is now the only bounds mode a capture can reach; the console verdict line and the `RENDER_STARTPOS`/`ENDPOS` read-back probe that answered this are gone. Full observation: `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection` -- [ ] **Still open — Auto and Manual tail.** `checkRenderedBounds` judges `TailMode::None` only (Auto/Manual add frames by design), so the settled result covers those two by INFERENCE, not observation: the floor applied to the bounds identically on all three tail modes, and all three now hand the window over the same way. What would establish it: repeat an off-grid-start capture at **Manual** over a source that is loud right to the window's end, and check the landed file's frames against window + `tailMs` — a floored edge shows up in that count. **Auto** cannot be checked by count (it trims trailing silence), so it needs the null test by ear/inversion against the source instead +- [ ] **Still open — Auto and Manual tail.** `checkRenderedBounds` judges `TailMode::None` only (Auto/Manual add frames by design), so the settled result covers those two by INFERENCE, not observation, and the inference rests on an unverified PREMISE too: that the (retired) floor applied to the bounds identically across all three tail modes, and that all three now hand the window over the same way. Neither is measured — both live short renders that settled the bounds mode were `TailMode::None`; no Auto or Manual capture has been observed at all. **On Auto/Manual, the ONLY automatic check left is the 0-byte gate (`checkRenderedFileNotEmpty`)** — there is no automatic bounds signal for those two modes at all until this bullet is closed by hand. What would establish it: repeat an off-grid-start capture at **Manual** over a source that is loud right to the window's end, and check the landed file's frames against window + `tailMs` — a floored edge shows up in that count. **Auto** cannot be checked by count (it trims trailing silence), so it needs the null test by ear/inversion against the source instead - [ ] `[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, a tail capture is silently SHORTER than its window with no detector at all. Report whether either tail capture comes up short against the source - [ ] A refused render is 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 - [ ] **If a capture is refused for a short render**, report the refusal line verbatim. A message naming `floored to the millisecond` means the floor is back on a mode measured escaping it; a shortfall of one or two frames with no such sentence may be the gate's own edge-convention tolerance rather than the render (`render_window.h`'s `renderHonoredBounds`) diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 6ba7b73..1bd3993 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -464,11 +464,9 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // millisecond (render_settings.h's kRenderBoundsTimeSelection). // // RENDER_STARTPOS/ENDPOS are written anyway, to the same window. The header - // (~3045-3046) documents them as mode-0-only, but the DAW run that settled this - // channel had both stores holding the identical window, so it cannot distinguish - // "mode 2 ignored them" from "mode 2 read them and they happened to agree". Writing - // them keeps the two stores agreeing rather than resting exactness on that - // distinction; a stale leftover here could only ever misalign a render silently. + // (~3045-3046) documents them as mode-0-only, so on mode 2 this is a cheap, + // fully-restored (ScopedRenderSettings) defensive write against that + // documentation being an incomplete account of what the renderer reads. GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", static_cast(kRenderBoundsTimeSelection), true); GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 1ae1b3c..952f964 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -65,9 +65,11 @@ static void testRealtimeIsUnsupportedOffline() { } static void testEveryRenderSourceLabelIsPinnedVerbatim() { - // docs/VERIFICATION.md asks Daniel to report the refusal's `Render source:` line - // back verbatim, so every label is pinned to its literal — a typo in any of them - // breaks the report that quotes it, and only a literal catches that. + // docs/VERIFICATION.md's short-render bullet asks Daniel to report the refusal + // line back verbatim, and that line always carries the render source + // (render_bounds_gate.cpp appends "Render source: