From a91df760ccc33cbe861d519ae9b3a341f46d34e8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 06:37:40 -0400 Subject: [PATCH] capture: name the render source in the exact-bounds refusal, and put its one-frame tolerance under test The tolerance is unchanged and now derived, not assumed: frameCountFor lands in {floor(L), ceil(L)}, so a non-frame-aligned window can never miss by more than a frame. Naming the source is what tells a self-bounding render from a short one. --- docs/VERIFICATION.md | 2 + src/core/capture/CLAUDE.md | 4 +- src/core/capture/render_settings.cpp | 12 +++++ src/core/capture/render_settings.h | 7 +++ src/core/capture/render_window.cpp | 7 +++ src/core/capture/render_window.h | 12 +++++ src/shell/capture/capture.cpp | 29 +++++----- tests/test_render_settings.cpp | 24 +++++++++ tests/test_render_window.cpp | 80 +++++++++++++++++++++++++++- 9 files changed, 157 insertions(+), 20 deletions(-) diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index ac26d70..33f9b8e 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -26,6 +26,8 @@ Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, i - [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`PLAN.md:2136`) - [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`PLAN.md:2137`) - [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md:829`) +- [ ] **Open blocker.** A live capture refused with a 38-frame shortfall (195216 of 195254 at 48 kHz). Set View → time unit to Samples, then over the same range run **track** scope and **item** scope in turn and report the refusal's `Render source:` line plus both frame counts, and whether the media under the range ends before the range does +- [ ] Same range extended ~1 s past all media, track scope — a full-length file with trailing silence means postprocessing is off; a short file means a trailing-silence trim is firing despite `RENDER_NORMALIZE &(4<<16)` ## Names and channels diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 19df546..15688d8 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -51,8 +51,8 @@ Detail specific to these pure modules: - `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives. - `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates. - `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`. -- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family. -- `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 widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. +- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal). +- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance and the derivation behind it), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. - `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. diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index 4939940..bfdb258 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -100,6 +100,18 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) { return c; } +const char* renderSourceLabel(SourceMode mode) { + switch (mode) { + case SourceMode::MasterMix: return "master mix"; + case SourceMode::TimeSelection: return "master mix (time selection)"; + case SourceMode::SelectedTracks: return "selected tracks via master"; + case SourceMode::SelectedItems: return "selected media items"; + case SourceMode::RazorArea: return "razor edits"; + case SourceMode::Realtime: return "realtime record"; + } + return "unknown"; // unreachable for a valid enum; never claim a source +} + 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 e3c120d..847b7fd 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -96,6 +96,13 @@ struct RenderSettingsChoice { // SelectedItems -> &32|single-file; RazorArea -> &4096|single-file. RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); +// The render source a mode drives, in words. Exists for the offline backend's +// 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. +const char* renderSourceLabel(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 df966cd..d24e436 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -24,6 +24,13 @@ long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) return frames > 0 ? frames : 0; } +bool renderHonoredBounds(long long expectedFrames, long long actualFrames) { + const long long delta = actualFrames > expectedFrames + ? actualFrames - expectedFrames + : expectedFrames - actualFrames; + return delta <= 1; +} + bool itemExtentPrintsWindow(double reqStart, double reqEnd, double itemStart, double itemEnd, int sampleRate) { diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index 60508fd..4702b42 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -18,6 +18,18 @@ namespace reasampler::capture { // whether the equality is exact or off by a frame. long long frameCountFor(double startSeconds, double endSeconds, int sampleRate); +// True when a landed render's frame count is consistent with `frameCountFor`'s +// answer for the same window. Tolerates a one-frame difference, and exactly one: +// frameCountFor rounds EACH edge, so it sits within a frame of the window's +// real-valued length (end-start)*rate — and a renderer that floors, ceils or +// rounds that same length sits within a frame of it too, so two integers derived +// that way can never be more than one apart. A window whose edges do not land on +// frame boundaries therefore cannot produce a larger difference; anything larger +// is a render that printed something other than the window asked for, whatever +// the alignment. Widening this past one frame retires the exact-bounds invariant +// rather than relaxing it — do not. +bool renderHonoredBounds(long long expectedFrames, long long actualFrames); + // True when a render bounded by the selected items' own extent // [itemStart, itemEnd) already prints exactly the requested // [reqStart, reqEnd) window — the one case where REAPER's selected-items render diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 3999df2..6929ce9 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -35,7 +35,7 @@ #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" // frameCountFor — the exact-bounds number +#include "core/capture/render_window.h" // frameCountFor / renderHonoredBounds — the exact-bounds gate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -499,9 +499,10 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { } // Exact bounds, made structural: with no tail requested the file must contain - // (within a tolerance, see below) the requested window's frames, so a source - // mode that silently widened the render fails loudly here instead of landing as - // a successful capture. Auto and Manual add frames by design and are skipped. + // the requested window's frames (renderHonoredBounds owns the tolerance and the + // reasoning behind it), so a render that printed something other than the window + // fails loudly here instead of landing as a successful capture. Auto and Manual + // add frames by design and are skipped. // (On TailMode::None the landed file is read three times on this path — this gate, // the mono collapse, and stampCaptureSample — plus one rewrite when the collapse // fires; Auto/Manual skip this gate entirely, so they read it twice. A @@ -518,24 +519,20 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { static_cast(layout.sampleRate)) : 0; const long long actualFrames = static_cast(layout.frameCount()); - // frameCountFor is a difference of frame indices, not a rounded duration - // (see render_window.h) — REAPER's own edge-rounding can legitimately land - // one frame off that, so the gate tolerates +/-1 rather than exact equality. - // The defect this refuses is a whole-item widening (seconds of extra audio, - // thousands of frames), which a 1-frame tolerance still catches with - // certainty. Tightening to exact equality needs a DAW pass confirming REAPER - // resolves the window's two edges to frame indices the same way this does. - const long long frameDelta = actualFrames > expectedFrames - ? actualFrames - expectedFrames - : expectedFrames - actualFrames; - if (expectedFrames > 0 && frameDelta > 1) { + if (expectedFrames > 0 && + !renderHonoredBounds(expectedFrames, actualFrames)) { result.status = CaptureStatus::BoundsMismatch; + // Naming the render source is load-bearing, not decoration: a source that + // derives its own bounds and a time-bounded render that came up short + // produce the same frame count, and only one of them is a routing defect. result.message = "Render produced " + std::to_string(actualFrames) + " frames but the requested range is " + std::to_string(expectedFrames) + " at " + std::to_string(layout.sampleRate) + " Hz -- the render did not honor the requested bounds. " - "Requested [" + std::to_string(request.startSeconds) + + "Render source: " + + renderSourceLabel(request.sourceMode) + + ". Requested [" + std::to_string(request.startSeconds) + "s, " + std::to_string(request.endSeconds) + "s) -> frame indices [" + std::to_string(std::llround(request.startSeconds * diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 3db957f..9ec4877 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -63,6 +64,28 @@ static void testRealtimeIsUnsupportedOffline() { CHECK(!renderSettingsFor(SourceMode::Realtime, 1.0).supported); } +static void testEveryRenderSourceIsNameable() { + // The bounds refusal quotes this label to say WHICH render source missed the + // window, so every mode must name itself and no two may read alike. + const SourceMode all[] = { + SourceMode::MasterMix, SourceMode::TimeSelection, SourceMode::SelectedTracks, + SourceMode::SelectedItems, SourceMode::RazorArea, SourceMode::Realtime, + }; + for (std::size_t i = 0; i < sizeof(all) / sizeof(all[0]); ++i) { + const char* label = renderSourceLabel(all[i]); + CHECK(label != nullptr && label[0] != '\0'); + CHECK(std::strcmp(label, "unknown") != 0); + for (std::size_t j = i + 1; j < sizeof(all) / sizeof(all[0]); ++j) + CHECK(std::strcmp(label, renderSourceLabel(all[j])) != 0); + } + // The two the refusal must tell apart: a source that derives its own bounds vs + // the time-bounded render. + CHECK(std::strcmp(renderSourceLabel(SourceMode::SelectedItems), + "selected media items") == 0); + CHECK(std::strcmp(renderSourceLabel(SourceMode::SelectedTracks), + "selected tracks via master") == 0); +} + // --- tail: TailMode -> RENDER_* mapping (docs/product/capture-tail.md) -------- static void testTailNoneIsExactBounds() { @@ -395,6 +418,7 @@ int main() { testSelectedItemsSingleFile(); testRazorSingleFile(); testRealtimeIsUnsupportedOffline(); + testEveryRenderSourceIsNameable(); testTailNoneIsExactBounds(); testTailAutoIsSurgicalTrim(); testAutoTrimRatioDerivesFromDb(); diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 6e7b8c6..1060df1 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -1,10 +1,12 @@ // Standalone tests for reasampler::render_window — no REAPER, no framework. // Covers the bounds-equality number (a window's exact frame count at the project -// rate) and the predicate that decides whether REAPER's selected-items render -// source can express a requested window at all. +// rate), the verdict the offline backend refuses a capture on, and the predicate +// that decides whether REAPER's selected-items render source can express a +// requested window at all. #include "../src/core/capture/render_window.h" +#include #include using namespace reasampler::capture; @@ -42,6 +44,74 @@ static void testFrameCountRefusesEmptyInvertedAndUnknownRate() { CHECK(frameCountFor(1.0, 2.0, -1) == 0); // rate nonsensical } +static void testWindowStartingAtExactlyZero() { + CHECK(frameCountFor(0.0, 1.0, 48000) == 48000); + // The window from the reported blocker: it starts at 0 and its end lands a + // quarter of a frame off the grid at 48 kHz. + CHECK(frameCountFor(0.0, 4.067797, 48000) == 195254); +} + +// --- renderHonoredBounds: the gate's verdict --------------------------------- + +static void testNonFrameAlignedWindowAcceptsEveryEdgeConvention() { + // 4.067797 s at 48 kHz is 195254.26 frames — not a frame boundary. A correct + // render lands on 195254, and the neighbours a different edge convention would + // produce are inside the gate. + const long long expected = frameCountFor(0.0, 4.067797, 48000); + CHECK(expected == 195254); + CHECK(renderHonoredBounds(expected, 195254)); + CHECK(renderHonoredBounds(expected, 195255)); + CHECK(renderHonoredBounds(expected, 195253)); + // The shortfall actually reported from the DAW is 38 frames — far outside any + // alignment slack, so it is a render that missed the window, and is refused. + CHECK(!renderHonoredBounds(expected, 195216)); +} + +static void testNonAlignmentCanNeverExceedOneFrame() { + // The provable content of the one-frame tolerance: frameCountFor rounds each + // edge, so it lands within a frame of the window's real length — and so does a + // renderer that floors, ceils or rounds that same length. Sweep both edges over + // every eighth of a frame; no pairing may fall outside the gate. The renderer's + // count is derived from the length here, independently of frameCountFor. + const int rate = 48000; + for (int s = 0; s < 8; ++s) { + for (int e = 0; e < 8; ++e) { + const double start = 3.0 + s / (8.0 * rate); + const double end = 7.5 + e / (8.0 * rate); + const long long expected = frameCountFor(start, end, rate); + const double length = (end - start) * rate; + CHECK(renderHonoredBounds(expected, + static_cast(std::floor(length)))); + CHECK(renderHonoredBounds(expected, + static_cast(std::ceil(length)))); + CHECK(renderHonoredBounds(expected, std::llround(length))); + } + } +} + +static void testWholeItemWideningIsStillRefused() { + // The defect the gate was built for: a 1 s window inside a 30 s item printing + // the whole item. + const long long expected = frameCountFor(5.0, 6.0, 48000); + CHECK(expected == 48000); + CHECK(!renderHonoredBounds(expected, 30 * 48000)); +} + +static void testLargeShortfallIsStillRefused() { + const long long expected = frameCountFor(0.0, 4.067797, 48000); + CHECK(!renderHonoredBounds(expected, 190000)); + // Two frames is the smallest miss outside the tolerance, in both directions — + // the tolerance is one frame and stays one frame. + CHECK(!renderHonoredBounds(expected, expected - 2)); + CHECK(!renderHonoredBounds(expected, expected + 2)); +} + +static void testEmptyRenderIsRefusedAgainstARealWindow() { + // A render that produced nothing is a bounds miss like any other; the backend's + // own "did the file parse" guard is what keeps an unreadable render out of here. + CHECK(!renderHonoredBounds(48000, 0)); +} + // --- itemExtentPrintsWindow: can the selected-items source express this? ----- static void testRangeInsideItemCannotBeExpressed() { @@ -106,6 +176,12 @@ int main() { testFrameCountIsExactNotRounded(); testFrameCountIsADifferenceOfIndicesNotADuration(); testFrameCountRefusesEmptyInvertedAndUnknownRate(); + testWindowStartingAtExactlyZero(); + testNonFrameAlignedWindowAcceptsEveryEdgeConvention(); + testNonAlignmentCanNeverExceedOneFrame(); + testWholeItemWideningIsStillRefused(); + testLargeShortfallIsStillRefused(); + testEmptyRenderIsRefusedAgainstARealWindow(); testRangeInsideItemCannotBeExpressed(); testRangeWiderThanItemCannotBeExpressedEither(); testEachEdgeAloneDisqualifies();