From 292d14d14c50051bfc8a5dbb4708044fa92cab81 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 14:28:03 -0400 Subject: [PATCH 01/48] Prove the render bounds at the boundary they cross, and name a short render whose count is exactly a millisecond-floored window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No truncation exists on our side of that boundary, so the read-back is the only evidence available for whether REAPER kept the window — and it fires on every tail mode, where only None was ever judged. --- src/core/capture/CLAUDE.md | 2 +- src/core/capture/render_window.cpp | 42 +++++++ src/core/capture/render_window.h | 31 ++++- src/shell/capture/capture.cpp | 19 +++ src/shell/capture/render_bounds_gate.cpp | 13 +- tests/test_render_window.cpp | 145 ++++++++++++++++++++++- 6 files changed, 246 insertions(+), 6 deletions(-) diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 104a57c..32c6ca3 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -52,7 +52,7 @@ Detail specific to these pure modules: - `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates. - `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`. - `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal). -- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. +- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the two short-render diagnostics: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched, quoted by the refusal as a count coincidence and nothing more) and `describeBoundsDrift` (the sentence the offline backend prints when `RENDER_STARTPOS`/`RENDER_ENDPOS` do not read back as they were written). - `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_window.cpp b/src/core/capture/render_window.cpp index e32e642..14dad44 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -3,6 +3,7 @@ #include "core/capture/render_window.h" #include +#include namespace reasampler::capture { @@ -15,6 +16,22 @@ long long frameIndexAt(double seconds, int sampleRate) { return std::llround(seconds * static_cast(sampleRate)); } +// See the header for why whole milliseconds get a tolerance and why it is this small. +double floorToMilliseconds(double seconds) { + const double ms = seconds * 1000.0; + const double nearest = std::nearbyint(ms); + if (std::fabs(ms - nearest) < 1e-6) return nearest / 1000.0; + 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) { @@ -41,4 +58,29 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, && frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate); } +long long msFlooredEndFrameCount(double startSeconds, double endSeconds, + int sampleRate) { + return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate); +} + +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 {}; + + std::string s = "REAPER did not keep the render bounds 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 a26bfdb..4c1113f 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -1,9 +1,12 @@ #pragma once // render_window — pure frame arithmetic for a capture's requested window: the -// frame count a project-time range occupies, and whether a render whose bounds -// come from the selected items' own extent already prints that window. +// frame count a project-time range occupies, whether a render whose bounds come +// from the selected items' own extent already prints that window, and the two +// diagnostics that say where a short render lost its frames. // NO REAPER types; unit-tested by tests/test_render_window.cpp. +#include + namespace reasampler::capture { // Frames the [startSeconds, endSeconds) window occupies at `sampleRate`. Both @@ -42,4 +45,28 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, double itemStart, double itemEnd, int sampleRate); +// --- Diagnostics: where a short render lost its frames ------------------------ + +// The frames this window would hold if its END were resolved on a whole-millisecond +// grid, floored, instead of exactly. Two live short renders (48 kHz, TailMode::None) +// matched this count to the frame, which is the entire reason it exists. +// +// A COINCIDENCE OF COUNTS, not a claim about how anything resolved the end: nothing +// renders from this number and no capture path asks for it. Whole-millisecond values +// are recognized within a nanosecond, because a decimal millisecond is not always one +// in binary (0.029 * 1000 lands just below 29) and a bare floor would drop a +// millisecond from a window already on the grid. A nanosecond is far under one frame +// at any rate we render, so a real sub-millisecond remainder still floors. +long long msFlooredEndFrameCount(double startSeconds, double endSeconds, + int sampleRate); + +// 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/capture.cpp b/src/shell/capture/capture.cpp index 6e7355a..b96a3c0 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -34,6 +34,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" // describeBoundsDrift — the read-back's verdict #include "shell/capture/render_bounds_gate.h" // the exact-bounds verdict on the landed render #define REAPERAPI_MINIMAL @@ -424,6 +425,12 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true); + // 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); + // 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 @@ -453,6 +460,18 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { GetSetProjectInfo(proj, "RENDER_SRATE", 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. + { + const std::string drift = + describeBoundsDrift(request.startSeconds, request.endSeconds, + storedStart, storedEnd, effectiveSampleRate); + if (!drift.empty()) + ShowConsoleMsg(("ReaSampler capture: " + drift + "\n").c_str()); + } + GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(request.channelCount), true); diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index 9fffa26..7528ec2 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -99,6 +99,17 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const long long actualFrames = static_cast(layout.frameCount()); if (renderHonoredBounds(expectedFrames, actualFrames)) return v; + // Says whether this shortfall has the one shape two live short renders already + // matched to the frame, so every refusal from here on adds to (or breaks) that + // evidence instead of needing the arithmetic done by hand. A count coincidence + // only — it does not establish how the render resolved anything. + const std::string msNote = + actualFrames == msFlooredEndFrameCount(request.startSeconds, + request.endSeconds, rate) + ? " Those are exactly the frames this window holds with its end floored to" + " the millisecond -- a match on the count, not a measured cause." + : std::string(); + v.refused = true; v.message = "Render produced " + std::to_string(actualFrames) + " frames but the requested range is " + std::to_string(expectedFrames) + @@ -108,7 +119,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, std::to_string(request.endSeconds) + "s) -> frame indices [" + std::to_string(std::llround(request.startSeconds * rate)) + ", " + std::to_string(std::llround(request.endSeconds * rate)) + ")." + - retainRefusedRender(renderedPath, projectDir); + msNote + retainRefusedRender(renderedPath, projectDir); return v; } diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index ee7cbc1..563c73e 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -1,13 +1,14 @@ // 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), the verdict the offline backend refuses a capture on, and the predicate +// 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. +// requested window at all, and the two short-render diagnostics. #include "../src/core/capture/render_window.h" #include #include +#include using namespace reasampler::capture; @@ -15,6 +16,10 @@ 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() { @@ -226,6 +231,131 @@ static void testMultiItemUnionExtent() { CHECK(!itemExtentPrintsWindow(1.0, 4.0, 1.0, 9.0, 48000)); } +// --- msFlooredEndFrameCount: the shape both live short renders had ------------ + +static void testMillisecondFlooredEndReproducesBothShortRenders() { + // Both DAW observations, as arithmetic. 48 kHz, TailMode::None, start at 0: the + // requested window's count, and the count its end floored to the millisecond + // holds — which is what each render actually printed. + CHECK(frameCountFor(0.0, 4.067797, 48000) == 195254); + CHECK(msFlooredEndFrameCount(0.0, 4.067797, 48000) == 195216); + CHECK(frameCountFor(0.0, 4.067797, 48000) - + msFlooredEndFrameCount(0.0, 4.067797, 48000) == 38); + + CHECK(frameCountFor(0.0, 1.655172, 48000) == 79448); + CHECK(msFlooredEndFrameCount(0.0, 1.655172, 48000) == 79440); + CHECK(frameCountFor(0.0, 1.655172, 48000) - + msFlooredEndFrameCount(0.0, 1.655172, 48000) == 8); +} + +static void testTheSixDecimalDisplayDidNotCreateTheEffect() { + // Both reported ends were printed to six decimals by the refusal. Each is one 4/4 + // bar — at 59 BPM and at 145 BPM — so the full-precision doubles behind them are + // 240/59 and 240/145. Same counts either way: the display rounding is not what + // produces the shortfall. + CHECK(frameCountFor(0.0, 240.0 / 59.0, 48000) == 195254); + CHECK(msFlooredEndFrameCount(0.0, 240.0 / 59.0, 48000) == 195216); + CHECK(frameCountFor(0.0, 240.0 / 145.0, 48000) == 79448); + CHECK(msFlooredEndFrameCount(0.0, 240.0 / 145.0, 48000) == 79440); +} + +static void testWindowAlreadyOnTheMillisecondGridLosesNothing() { + // The "sometimes it works" case: a bar at 120 BPM is exactly 2 s. + CHECK(msFlooredEndFrameCount(0.0, 2.0, 48000) == frameCountFor(0.0, 2.0, 48000)); + + // The binary-representation trap a bare floor would fall into. The premise, not an + // outcome: 1.007 s is a whole millisecond that really does land BELOW 1007 ms in + // double, so flooring it without a tolerance drops a millisecond from a window + // already on the grid. + CHECK(1.007 * 1000.0 < 1007.0); + CHECK(frameCountFor(0.0, 1.007, 48000) == 48336); + CHECK(msFlooredEndFrameCount(0.0, 1.007, 48000) == 48336); + // Same end reached from a non-zero start, so nothing here rests on the window + // beginning at 0. + CHECK(msFlooredEndFrameCount(0.5, 1.007, 48000) == + frameCountFor(0.5, 1.007, 48000)); +} + +static void testOneFrameOfRemainderStillFloors() { + // The whole-millisecond tolerance must sit far below a frame, or it would swallow + // the very remainder this diagnostic exists to find. One frame at 48 kHz is 20.8 us + // — four orders of magnitude above the nanosecond tolerance. + const double oneFrame = 1.0 / 48000.0; + CHECK(frameCountFor(0.0, 1.0 + oneFrame, 48000) == 48001); + CHECK(msFlooredEndFrameCount(0.0, 1.0 + oneFrame, 48000) == 48000); +} + +static void testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames() { + // 44.1 kHz: a millisecond is 44.1 frames, so a floored end cannot be described as + // dropping a whole number of frames — the count still resolves exactly. + CHECK(frameCountFor(0.0, 0.0105, 44100) == 463); + CHECK(msFlooredEndFrameCount(0.0, 0.0105, 44100) == 441); + // And a window that IS on the millisecond grid there is untouched, even though its + // edge is not on a frame boundary. + CHECK(frameCountFor(0.0, 0.010, 44100) == 441); + CHECK(msFlooredEndFrameCount(0.0, 0.010, 44100) == 441); +} + +static void testASubMillisecondStartWouldNotHideItself() { + // Both observations started at 0.000000s, the one value that hides a start-side + // truncation. A window whose START carries a sub-millisecond remainder counts from + // that exact start... + const double start = 1.0001724, end = 2.0001724; + CHECK(frameCountFor(start, end, 48000) == 48000); + // ...so a start floored to the millisecond would print a DIFFERENT count — 8 frames + // more, the same remainder the second observation lost off its end. A start-side + // truncation is therefore visible to the same frame-count gate, not silent. + CHECK(frameCountFor(1.000, end, 48000) == 48008); + CHECK(!renderHonoredBounds(frameCountFor(start, end, 48000), + frameCountFor(1.000, end, 48000))); +} + +// --- 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. + const double asked = 4.067797; + const double stored = std::nextafter(asked, 5.0); + const std::string s = describeBoundsDrift(0.0, asked, 0.0, stored, 48000); + CHECK(!s.empty()); + CHECK(!contains(s, "4.067797s, read back [0s, 4.067797s)")); +} + +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")); +} + int main() { testFrameCountIsExactNotRounded(); testFrameCountIsADifferenceOfIndicesNotADuration(); @@ -244,6 +374,17 @@ int main() { testSubFrameDriftStillPrintsTheSameFrames(); testUnknownRateFallsBackToExactEquality(); testMultiItemUnionExtent(); + testMillisecondFlooredEndReproducesBothShortRenders(); + testTheSixDecimalDisplayDidNotCreateTheEffect(); + testWindowAlreadyOnTheMillisecondGridLosesNothing(); + testOneFrameOfRemainderStillFloors(); + testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames(); + testASubMillisecondStartWouldNotHideItself(); + testBoundsThatReadBackUnchangedDescribeNothing(); + testADriftedEndNamesBothWindowsAndBothCounts(); + testTheReportPrintsEnoughDigitsToShowTheDrift(); + testADriftedStartIsCaughtToo(); + testAnUnknownRateStillReportsTheDriftWithoutFrames(); if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; } std::printf("render_window: all checks passed\n"); From 0ab467388701eb5835a2f8fbbb40bc997ca70bef Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 14:45:42 -0400 Subject: [PATCH 02/48] Fix eight review findings on the render-bounds diagnostics Corrects a false comment example, fixes two tests that couldn't detect their own regressions, adds two more read-back checkpoints around Main_OnCommand so a drift report self-locates, guards a spurious zero-vs-zero coincidence match, and softens two sentences that overclaimed cause or defect. --- src/core/capture/render_window.cpp | 6 ++++- src/core/capture/render_window.h | 6 +++-- src/shell/capture/capture.cpp | 28 ++++++++++++++++---- src/shell/capture/capture_realtime_shell.cpp | 5 ++++ src/shell/capture/render_bounds_gate.cpp | 15 +++++++---- tests/test_render_window.cpp | 27 ++++++++++++++----- 6 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index 14dad44..40e2840 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -70,7 +70,11 @@ std::string describeBoundsDrift(double reqStart, double reqEnd, // straight back, so anything but the same bits is a value REAPER changed. if (storedStart == reqStart && storedEnd == reqEnd) return {}; - std::string s = "REAPER did not keep the render bounds it was handed -- asked for [" + + // 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) { diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index 4c1113f..3f96e6f 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -2,7 +2,9 @@ // render_window — pure frame arithmetic for a capture's requested window: the // frame count a project-time range occupies, whether a render whose bounds come // from the selected items' own extent already prints that window, and the two -// diagnostics that say where a short render lost its frames. +// diagnostics that bound a short render without locating it: whether the stored +// RENDER_* bounds round-tripped, and whether the shortfall matches a millisecond- +// floor coincidence. // NO REAPER types; unit-tested by tests/test_render_window.cpp. #include @@ -54,7 +56,7 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, // A COINCIDENCE OF COUNTS, not a claim about how anything resolved the end: nothing // renders from this number and no capture path asks for it. Whole-millisecond values // are recognized within a nanosecond, because a decimal millisecond is not always one -// in binary (0.029 * 1000 lands just below 29) and a bare floor would drop a +// in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a // millisecond from a window already on the grid. A nanosecond is far under one frame // at any rate we render, so a real sub-millisecond remainder still floors. long long msFlooredEndFrameCount(double startSeconds, double endSeconds, diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index b96a3c0..9c71758 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -463,14 +463,18 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // 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. - { + // 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, - storedStart, storedEnd, effectiveSampleRate); + atStart, atEnd, effectiveSampleRate); if (!drift.empty()) - ShowConsoleMsg(("ReaSampler capture: " + drift + "\n").c_str()); - } + ShowConsoleMsg(("ReaSampler capture (" + std::string(checkpoint) + "): " + + drift + "\n").c_str()); + }; + reportDrift("at store", storedStart, storedEnd); GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(request.channelCount), true); @@ -504,8 +508,22 @@ 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. + reportDrift("before render", + GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false), + GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false)); + 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", + GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false), + GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false)); + // 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; diff --git a/src/shell/capture/capture_realtime_shell.cpp b/src/shell/capture/capture_realtime_shell.cpp index 41a307b..e596091 100644 --- a/src/shell/capture/capture_realtime_shell.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -369,6 +369,11 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, // recordWindowEnd extends past the range end for a tail mode so the // transport captures the decay; cursor + time selection are restored by restore(). + // `[verify — DAW]` whether rs/re come back changed on this isSet=true call: the SDK + // header names both `double*` but documents no read-back semantics for either + // direction, and nothing here reads rs/re again after the call to notice. Lower + // stakes than the offline RENDER_* store: completion is driven by the play cursor + // reaching the range end (tick(), below), not by re-reading this pair. double rs = request.startSeconds, re = st->recordWindowEnd_; GetSet_LoopTimeRange(true, false, &rs, &re, false); SetEditCurPos(request.startSeconds, false, false); diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index 7528ec2..c0b7311 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -100,12 +100,17 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, if (renderHonoredBounds(expectedFrames, actualFrames)) return v; // Says whether this shortfall has the one shape two live short renders already - // matched to the frame, so every refusal from here on adds to (or breaks) that - // evidence instead of needing the arithmetic done by hand. A count coincidence - // only — it does not establish how the render resolved anything. + // matched to the frame: the END alone floored to the millisecond. Checked against + // the END only -- a refusal whose START is also off-grid and independently floored + // would not match this shape, and this note's silence on that refusal is this + // check not covering it, not the coincidence breaking. Excludes 0, which every + // sub-millisecond window (a legitimate day-one capture) also floors to, and which + // would otherwise match a render that produced nothing. A count coincidence only — + // it does not establish how the render resolved anything. + const long long msFlooredEnd = + msFlooredEndFrameCount(request.startSeconds, request.endSeconds, rate); const std::string msNote = - actualFrames == msFlooredEndFrameCount(request.startSeconds, - request.endSeconds, rate) + (msFlooredEnd > 0 && actualFrames == msFlooredEnd) ? " Those are exactly the frames this window holds with its end floored to" " the millisecond -- a match on the count, not a measured cause." : std::string(); diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 563c73e..9e0f0b6 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -278,11 +278,16 @@ static void testWindowAlreadyOnTheMillisecondGridLosesNothing() { static void testOneFrameOfRemainderStillFloors() { // The whole-millisecond tolerance must sit far below a frame, or it would swallow - // the very remainder this diagnostic exists to find. One frame at 48 kHz is 20.8 us - // — four orders of magnitude above the nanosecond tolerance. + // the very remainder this diagnostic exists to find. A remainder JUST BELOW a + // millisecond boundary is the discriminating case: one frame short of 1.0 s is + // 999.979166 ms, only ~0.0208 ms off the next whole millisecond. The shipped + // nanosecond tolerance still floors it down; a tolerance any wider than ~0.021 ms + // would snap it up to the millisecond instead and this test would then see 48000, + // not 47952 — which is what would fail if the tolerance regressed to something + // that wide. const double oneFrame = 1.0 / 48000.0; - CHECK(frameCountFor(0.0, 1.0 + oneFrame, 48000) == 48001); - CHECK(msFlooredEndFrameCount(0.0, 1.0 + oneFrame, 48000) == 48000); + CHECK(frameCountFor(0.0, 1.0 - oneFrame, 48000) == 47999); + CHECK(msFlooredEndFrameCount(0.0, 1.0 - oneFrame, 48000) == 47952); } static void testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames() { @@ -333,12 +338,22 @@ static void testADriftedEndNamesBothWindowsAndBothCounts() { 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. + // — 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, "4.067797s, read back [0s, 4.067797s)")); + CHECK(contains(s, askedBuf)); + CHECK(contains(s, storedBuf)); } static void testADriftedStartIsCaughtToo() { From 5c0f5f1591b1eff587cbbc9614f1e8f87235a0ed Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 14:17:55 -0400 Subject: [PATCH 03/48] Render in place: a track's output to a new sibling, source to the bench --- CLAUDE.md | 2 +- src/app/CMakeLists.txt | 1 + src/app/main.cpp | 7 + src/core/capture/capture_name.cpp | 13 ++ src/core/capture/capture_name.h | 12 ++ src/core/capture/capture_paths.cpp | 34 ++-- src/core/capture/capture_paths.h | 26 ++- src/core/capture/track_topology.cpp | 34 ++++ src/core/capture/track_topology.h | 36 ++++- src/core/view/CLAUDE.md | 5 +- src/shell/actions/CLAUDE.md | 6 +- src/shell/capture/CLAUDE.md | 11 +- src/shell/capture/capture.cpp | 36 ++++- src/shell/capture/capture.h | 16 ++ src/shell/capture/render_in_place.cpp | 223 ++++++++++++++++++++++++++ src/shell/capture/render_in_place.h | 18 +++ src/shell/panel/panel_input.cpp | 11 +- tests/test_capture_name.cpp | 41 +++++ tests/test_capture_paths.cpp | 36 +++++ tests/test_track_topology.cpp | 148 +++++++++++++++++ tests/test_view_mode_model.cpp | 43 +++++ 21 files changed, 727 insertions(+), 32 deletions(-) create mode 100644 src/shell/capture/render_in_place.cpp create mode 100644 src/shell/capture/render_in_place.h diff --git a/CLAUDE.md b/CLAUDE.md index 0529fc8..6d5a3d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,7 @@ Plan-style docs live under `docs/`: ## The load-bearing principle -**Capture and placement are separate acts.** Capturing audio writes a file to the bank and adds an index entry. It **never** puts an item in the arrange view. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**. +**Capture and placement are separate acts.** Capturing audio writes a file to the bank and adds an index entry. It **never** puts an item in the arrange view. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**. A render that goes arrange → arrange, never entering the bank and never reading it (`shell/capture/render_in_place`), is a THIRD verb outside this rule rather than a softening of it — the rule binds anything that touches the bank on either side, so a bank sample may still only reach the timeline through an on-demand placement, and a capture may never grow a place step. ## Precision invariants — required before any feature ships diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 75ca5b7..044ab51 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp ${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp ${REASAMPLER_SRC_DIR}/shell/capture/render_bounds_gate.cpp + ${REASAMPLER_SRC_DIR}/shell/capture/render_in_place.cpp ${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index c6f8136..57696ae 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -31,6 +31,7 @@ #include "shell/capture/capture_batch.h" // batch + recapture action bodies #include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver +#include "shell/capture/render_in_place.h" // render-in-place action body #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession @@ -88,6 +89,7 @@ static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session) static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); } static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } +static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); } static void RunResampleBake(int) { capture::RunResampleBake(g_session); } static void RunShowVersion(int) { // On-demand only — no unconditional startup print (routine console chatter pops @@ -134,6 +136,11 @@ static std::vector buildMainActionTable() { &RunCancelRealtime}); rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source", &RunRecaptureFromSource}); + // A RENDER_*, not a CAPTURE_*: the id is permanent and is the most durable + // statement the codebase makes about which pillar a feature belongs to. + rows.push_back({"RENDER_TRACK_IN_PLACE", + "render selected track to a new track (source moves to Design)", + &RunRenderTrackInPlace}); // Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a // stranded request can be landed by hand). The suffix is the wire contract itself — // core/wire/bake_wire owns the spelling both artifacts read. diff --git a/src/core/capture/capture_name.cpp b/src/core/capture/capture_name.cpp index c8aca5c..9126cc3 100644 --- a/src/core/capture/capture_name.cpp +++ b/src/core/capture/capture_name.cpp @@ -86,4 +86,17 @@ CaptureName composeCaptureName(const CaptureNameInputs& in) { return out; } +std::string captureTrackName(const std::string& sourceName) { + const std::string prefix(kCaptureTrackPrefix); + // A source with no readable name yields the bare word rather than a trailing + // space; both spellings are fixed points, which is what makes the whole function + // one (a track named exactly "Capture" must not become "Capture Capture"). + const std::string bare = prefix.substr(0, prefix.size() - 1); + + if (sourceName.empty()) return bare; + if (sourceName == bare) return sourceName; + if (sourceName.rfind(prefix, 0) == 0) return sourceName; + return prefix + sourceName; +} + } // namespace reasampler::capture diff --git a/src/core/capture/capture_name.h b/src/core/capture/capture_name.h index 803ac60..1e7133b 100644 --- a/src/core/capture/capture_name.h +++ b/src/core/capture/capture_name.h @@ -59,4 +59,16 @@ std::string formatCaptureStamp(const CaptureStamp& stamp); CaptureName composeCaptureName(const CaptureNameInputs& in); +// Prefixed onto a source track's name to name the track a render-in-place created. +// A display convention, not a persisted key — unlike a lane prefix or an action-id +// suffix, changing it later strands nothing. +inline constexpr const char* kCaptureTrackPrefix = "Capture "; + +// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on +// its own output, so a second render over a result track yields "Capture MONEY" +// again rather than "Capture Capture MONEY". A counter suffix is deliberately not +// offered: REAPER does not uniquify track names either, and what distinguishes two +// renders of one source is their position, not their name. +std::string captureTrackName(const std::string& sourceName); + } // namespace reasampler::capture diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 5d50501..e3ab731 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -45,16 +45,25 @@ std::string sanitizeStem(const std::string& baseName) { return out; } -BankPaths deriveBankPaths(const std::string& projectDir, - const std::string& baseName, - const std::string& uniqueTag) { - const std::string dir = normalizeSlashes(projectDir); - +RenderPaths deriveRenderPaths(const std::string& absoluteDir, + const std::string& baseName, + const std::string& uniqueTag) { std::string stem = sanitizeStem(baseName); if (!uniqueTag.empty()) { stem += "_" + sanitizeStem(uniqueTag); } - const std::string fileName = stem + ".wav"; + + RenderPaths r; + r.fileStem = stem; // stem only — REAPER appends the extension + r.fileName = stem + ".wav"; + r.absoluteDir = normalizeSlashes(absoluteDir); + return r; +} + +BankPaths deriveBankPaths(const std::string& projectDir, + const std::string& baseName, + const std::string& uniqueTag) { + const std::string dir = normalizeSlashes(projectDir); // Precondition: caller must resolve a non-empty project directory — an // empty one would otherwise fall back to a bare relative path (forbidden). @@ -62,18 +71,19 @@ BankPaths deriveBankPaths(const std::string& projectDir, // ignores it fails at the render/stat step, not silently onto CWD. assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty"); + const RenderPaths r = deriveRenderPaths( + dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag); + BankPaths p; - p.fileStem = stem; // stem only — REAPER appends extension - p.fileName = fileName; - p.relativePath = std::string(kBankSubfolder) + "/" + fileName; - p.absoluteDir = dir.empty() ? std::string{} - : dir + "/" + kBankSubfolder; + p.fileStem = r.fileStem; + p.fileName = r.fileName; + p.relativePath = bankRelativeForName(r.fileName); + p.absoluteDir = r.absoluteDir; return p; } std::string bankRelativeForName(const std::string& fileName) { if (fileName.empty()) return {}; - // Same expression deriveBankPaths uses, so the two spellings can't drift. return std::string(kBankSubfolder) + "/" + fileName; } diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 8ca0157..77049b1 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -36,9 +36,29 @@ std::string normalizeSlashes(const std::string& path); // "capture" if nothing usable remains. Deterministic. std::string sanitizeStem(const std::string& baseName); -// Derives the bank paths for one capture: baseName is the sanitized file-stem -// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so -// repeated captures don't collide. Produces "[_].wav". +// Where one render writes, with no index spelling at all: the directory REAPER is +// told to render into plus the stem/file name it produces there. `absoluteDir` is +// taken as given (normalized only) rather than derived, because a render that never +// enters the bank has no bank subfolder to append — the render-in-place verb points +// this at the project's own recording path. +struct RenderPaths { + std::string absoluteDir; // RENDER_FILE (forward slash, no trailing slash) + std::string fileName; // .wav + std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) +}; + +// The file-stem spelling for one render: baseName is the sanitized file-stem source, +// uniqueTag an optional sanitized disambiguator (timestamp/counter) so repeated +// renders don't collide. Produces "[_].wav". THE one owner of that +// spelling — deriveBankPaths is expressed over it, and bankRelativeForName depends +// on the bank's spelling never drifting from it. +RenderPaths deriveRenderPaths(const std::string& absoluteDir, + const std::string& baseName, + const std::string& uniqueTag); + +// Derives the bank paths for one capture: the same stem spelling as +// deriveRenderPaths, in the bank subfolder, plus the project-relative path the +// index stores. BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); diff --git a/src/core/capture/track_topology.cpp b/src/core/capture/track_topology.cpp index 58c9841..5db98ff 100644 --- a/src/core/capture/track_topology.cpp +++ b/src/core/capture/track_topology.cpp @@ -25,4 +25,38 @@ std::vector directChildIndices(const std::vector& folderDepths, return children; } +SiblingPlacement siblingPlacement(const std::vector& folderDepths, int srcIndex) { + const int count = static_cast(folderDepths.size()); + if (count == 0) return SiblingPlacement{}; + + const int src = srcIndex < 0 ? 0 : (srcIndex >= count ? count - 1 : srcIndex); + + // levels[i] is track i's absolute nesting depth; levels[count] is the depth the + // list closes at (0 in a well-formed project). Negative is unrepresentable, so a + // malformed over-closing delta clamps here rather than propagating. + std::vector levels(static_cast(count) + 1, 0); + for (int i = 0; i < count; ++i) { + const int next = levels[static_cast(i)] + + folderDepths[static_cast(i)]; + levels[static_cast(i) + 1] = next < 0 ? 0 : next; + } + + const int L = levels[static_cast(src)]; + + int p = src + 1; + if (folderDepths[static_cast(src)] >= 1) { + p = count; // an unterminated folder swallows the rest of the list + for (int j = src + 1; j <= count; ++j) { + if (levels[static_cast(j)] == L) { p = j; break; } + } + } + + SiblingPlacement out; + out.insertIndex = p; + out.precedingIndex = p - 1; + out.precedingDepth = L - levels[static_cast(p - 1)]; + out.newDepth = levels[static_cast(p)] - L; + return out; +} + } // namespace reasampler::capture diff --git a/src/core/capture/track_topology.h b/src/core/capture/track_topology.h index 2a55fa2..96cc567 100644 --- a/src/core/capture/track_topology.h +++ b/src/core/capture/track_topology.h @@ -1,8 +1,8 @@ #pragma once // track_topology — pure folder arithmetic over a project's track list: which tracks -// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas -// alone. NO REAPER types (the shell reads the deltas); unit-tested by -// tests/test_track_topology.cpp. +// are the DIRECT children of a folder parent, and where a new SIBLING of a given +// track goes, both derived from the I_FOLDERDEPTH deltas alone. NO REAPER types +// (the shell reads the deltas); unit-tested by tests/test_track_topology.cpp. #include @@ -21,4 +21,34 @@ namespace reasampler::capture { std::vector directChildIndices(const std::vector& folderDepths, int parentIndex); +// Where a new track goes so it is a SIBLING of `srcIndex` — same nesting level, same +// folder — and the two I_FOLDERDEPTH writes that put it there. +struct SiblingPlacement { + int insertIndex = 0; // the index the new track occupies after insertion + + // The track that will PRECEDE the new one (insertIndex - 1), and its rewritten + // delta. -1 only for a degenerate empty list, where there is nothing to write. + int precedingIndex = -1; + int precedingDepth = 0; + + int newDepth = 0; // the new track's own I_FOLDERDEPTH +}; + +// Both naive answers are audibly wrong, which is why this is arithmetic and not +// `srcIndex + 1`: inserting straight after a folder PARENT makes the new track that +// folder's first child (its audio re-enters the parent's FX and fader), and inserting +// straight after the folder's LAST track steals that track's closing delta and drops +// the new one outside the folder entirely (its audio bypasses the folder bus). +// +// Levels are absolute nesting depths recovered from the deltas (level[0] = 0, +// level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first +// following track back at the source's own level — i.e. after the whole folder; +// everything else inserts directly below the source. The two writes preserve the +// total delta sum, so no track after the insertion changes level. +// +// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to +// the nearest legal placement rather than asserting: the failure mode of a corrupt +// project must be a track at the wrong nesting level, never a crash. +SiblingPlacement siblingPlacement(const std::vector& folderDepths, int srcIndex); + } // namespace reasampler::capture diff --git a/src/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index b8ecfbd..e3a64b1 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -65,7 +65,10 @@ settled 2026-07-23): play/show so only the active mode's lane is present. Items keep their real position and real track — nothing is moved in time or deleted. - **Membership: adoption rule for new items; active mode for new tracks.** New - tracks are tagged to the active mode at creation. New items follow an + tracks are tagged to the active mode at creation **only when the GUID carries no + membership record** — an explicit tag wins over the detector, because the detector + classifies content the *user* made, not content the tool made and already + classified. New items follow an adoption rule: if the item's track has pre-existing managed-eligible content spanning exactly one mode, the item adopts that mode; the active-mode fallback applies only when the track is empty or already spans multiple diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index e5443df..befbaed 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -16,8 +16,10 @@ is owned by other directories and only skinned here. - **Ingest is an extension act; the instrument is a read-only bank consumer.** Any instrument code path that captures, imports, inserts a timeline item, or writes back into the bank is a bug — the instrument reads and plays only. -- **`arrange_drop_win` is the only timeline-placing shell in this directory**, and - it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s +- **`arrange_drop_win` is the only timeline-placing shell IN THIS DIRECTORY** — the + claim scopes here, not to the system: `shell/capture` holds two more + (`RunInsertSelected` and `render_in_place`, the third verb). `arrange_drop_win` + places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s capture/placement separation forbids a CAPTURE placing an item; a deliberate drop is placement on demand. No other module here may grow an `InsertMedia` call. - **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index ea057b2..94fb9d2 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -45,9 +45,13 @@ detail not covered there: `capture_realtime_shell` cannot block REAPER's UI for the duration of a realtime record, so `begin`/`tick`/`abort` are async by construction and the temp-track + send recipe lives in the shell, not the pure core. -- **`RunInsertSelected` is the one deliberate exception to capture-never-places** - (see `capture_orchestrator` below) — every other capture entry point writes only - a file + index entry. +- **This directory hosts TWO placing paths, and neither is a capture placing + itself.** `RunInsertSelected` (see `capture_orchestrator` below) places a *bank + sample*, on demand, which is why it is the deliberate exception to + capture-never-places. `render_in_place` places a render that never entered the + bank — the third verb (arrange → arrange, root `CLAUDE.md` §The load-bearing + principle). Every other entry point here writes only a file + index entry, and no + capture may ever grow a place step. ## Modules @@ -63,6 +67,7 @@ detail not covered there: - `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. - `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). - `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`. +- `render_in_place` (`shell/capture`) — the third verb, arrange → arrange: renders the selected track's output over the resolved range through `renderOffline` with `CaptureDestination::ProjectMedia`, then places the result on a brand-new sibling track at the render window's exact start (unsnapped — this placement IS the null test performed automatically), clones the source's colour and its name through the idempotent `captureTrackName`, and settles both tracks' modes in ONE `UNDO_STATE_ALL` block. Sibling nesting comes from the pure `core/capture/track_topology::siblingPlacement`. The source is tagged Design and the result track + its items are tagged `kArrangeModeId` **explicitly and unconditionally** — never `view.activeModeId()`, and never `untag()`, because the panel's auto-tag detector defers to a membership RECORD. It reads and writes NOTHING in the bank: no `session.bank()`, no `session.book()`, no `recordCreated`, no `bumpBankGeneration`; the `Sample` the backend returns is discarded and its `relativePath` is empty by construction. Traffic is one-way — capture may borrow this render, this placement may never be borrowed back into a capture. - `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free. - `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively. - `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys. diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 9c71758..4fdf6e4 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -39,6 +39,7 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetProjectPathEx #define REAPERAPI_WANT_GetSetProjectInfo #define REAPERAPI_WANT_GetSetProjectInfo_String #define REAPERAPI_WANT_GetSet_LoopTimeRange @@ -414,8 +415,29 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // Compute the tag ONCE — calling makeUniqueTag() twice would let the file // stem and Sample.id diverge (the counter advances per call). const std::string uniqueTag = makeUniqueTag(""); - const BankPaths paths = - deriveBankPaths(projectDir, request.baseName, uniqueTag); + + // Destination resolves HERE, after the save gate above, so an unsaved project is + // still prompted before any path arithmetic runs. ProjectMedia lands outside the + // bank folder and leaves relativePath empty — the Sample it produces indexes + // nothing (docs/product/render-in-place.md §"Where the file goes"). + RenderPaths paths; + std::string relativePath; + if (request.destination == CaptureDestination::Bank) { + const BankPaths bank = + deriveBankPaths(projectDir, request.baseName, uniqueTag); + paths = RenderPaths{bank.absoluteDir, bank.fileName, bank.fileStem}; + relativePath = bank.relativePath; + } else { + std::vector recDir(4096, '\0'); + GetProjectPathEx(proj, recDir.data(), static_cast(recDir.size())); + paths = deriveRenderPaths(std::string(recDir.data()), request.baseName, + uniqueTag); + if (paths.absoluteDir.empty()) { + result.status = CaptureStatus::NoProject; + result.message = "Could not resolve the project's recording path."; + return result; + } + } ScopedRenderSettings guard(proj); @@ -575,7 +597,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // yield a different value and desync Sample.id from the file name. s.id = "cap-" + uniqueTag + "-" + paths.fileName; s.displayName = request.label(); - s.relativePath = paths.relativePath; // project-relative (invariant) + s.relativePath = relativePath; // project-relative (invariant); empty off the bank s.sourceMode = request.sourceMode; s.sourceRange.startSeconds = request.startSeconds; s.sourceRange.endSeconds = request.endSeconds; @@ -590,12 +612,14 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // single played note, so no root note is derivable; loop points are set // later by an explicit user action. - result.status = CaptureStatus::Ok; - result.sample = s; + result.status = CaptureStatus::Ok; + result.sample = s; + result.absolutePath = expectedPath; result.message = "Captured [" + std::to_string(request.startSeconds) + "s, " + std::to_string(request.endSeconds) + "s] -> " + - paths.relativePath + monoCollapseSuffix(collapseOutcome); + (relativePath.empty() ? expectedPath : relativePath) + + monoCollapseSuffix(collapseOutcome); return result; } diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index c52b460..dc640f1 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -30,6 +30,14 @@ enum class WavBitDepth { Float32, }; +// Where the render lands. TWO VALUES, never a caller-supplied path string: the +// backend resolves each to a directory itself, which is what makes "write into the +// bank folder" inexpressible from the ProjectMedia side and vice versa. +enum class CaptureDestination { + Bank, // /reasampler_bank — every capture path + ProjectMedia, // the project's recording path — the render-in-place verb only +}; + // One capture, independent of source mode. struct CaptureRequest { SourceMode sourceMode = SourceMode::MasterMix; @@ -75,6 +83,9 @@ struct CaptureRequest { // The one home for that fallback rule; both backends populate Sample::displayName // from here rather than each spelling the condition out. std::string label() const { return displayName.empty() ? baseName : displayName; } + + // Default Bank: every existing entry point renders into the bank untouched. + CaptureDestination destination = CaptureDestination::Bank; }; // Every failure is an explicit code, never a thrown exception across the REAPER boundary. @@ -95,6 +106,11 @@ struct CaptureResult { CaptureStatus status = CaptureStatus::RenderFailed; Sample sample; // valid only when status == Ok std::string message; // human-readable detail for the console log + + // The file the render actually landed, absolute — the only handle a caller that + // banks nothing has on its own output (sample.relativePath is empty on the + // ProjectMedia destination). Set on the Ok path only. + std::string absolutePath; }; // Deterministic offline-render backend: master mix / time selection / selected diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp new file mode 100644 index 0000000..dd53752 --- /dev/null +++ b/src/shell/capture/render_in_place.cpp @@ -0,0 +1,223 @@ +// render_in_place.cpp — see render_in_place.h. +// +// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the +// one TU that defines the API pointers; here they are extern. +// +// Traffic is one-way: this borrows capture's render, and capture may never borrow +// this placement back. + +#include "shell/capture/render_in_place.h" + +#include +#include + +#include "core/capture/capture_name.h" // captureTrackName +#include "core/capture/insert_plan.h" // computeInsertMode / InsertOptions +#include "core/capture/render_settings.h" // CaptureScope +#include "core/capture/tail_control.h" // TailSetting +#include "core/capture/track_topology.h" // siblingPlacement +#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId +#include "shell/capture/capture.h" +#include "shell/capture/capture_orchestrator.h" // renderOffline +#include "shell/capture/item_read.h" // itemGuid +#include "shell/capture/scope_resolve.h" // ResolveScopeSource / trackName +#include "shell/capture/track_guid.h" // guidString +#include "shell/panel/panel_input.h" // bankPanelTailSetting +#include "shell/persist/session.h" +#include "shell/view/view.h" // applyMode / mintManagedLanes + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetCursorPosition +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetProjectPathEx +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetTrackColor +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_InsertMedia +#define REAPERAPI_WANT_InsertTrackInProject +#define REAPERAPI_WANT_SetEditCurPos +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_SetOnlyTrackSelected +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_TrackList_AdjustWindows +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +void refuse(const std::string& why) { + ShowConsoleMsg(("ReaSampler render in place: " + why + "\n").c_str()); +} + +// Every track's I_FOLDERDEPTH in track order — the flat delta list the pure +// sibling arithmetic reads. +std::vector folderDepths(ReaProject* proj, int count) { + std::vector depths; + depths.reserve(static_cast(count < 0 ? 0 : count)); + for (int i = 0; i < count; ++i) { + MediaTrack* tr = GetTrack(proj, i); + depths.push_back(tr ? static_cast( + GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")) + : 0); + } + return depths; +} + +int indexOfTrack(ReaProject* proj, int count, MediaTrack* wanted) { + for (int i = 0; i < count; ++i) + if (GetTrack(proj, i) == wanted) return i; + return -1; +} + +void setTrackName(MediaTrack* tr, const std::string& name) { + // GetSetMediaTrackInfo_String takes a writable buffer even on the set path. + std::vector buf(name.begin(), name.end()); + buf.push_back('\0'); + GetSetMediaTrackInfo_String(tr, "P_NAME", buf.data(), true); +} + +} // namespace + +void RunRenderTrackInPlace(ReaSamplerSession& session) { + ResolvedSource src; + std::string why; + if (!ResolveScopeSource(CaptureScope::Track, src, why)) { refuse(why); return; } + if (src.sourceTracks.empty() || !src.sourceTracks.front()) { + refuse("no source track resolved"); return; + } + + const TailSetting tail = bankPanelTailSetting(); + const CaptureName name = captureNameFor(src.trackNames, /*ordinal=*/0, "capture"); + + CaptureRequest req; + req.sourceMode = SourceMode::SelectedTracks; + req.startSeconds = src.startSeconds; // exact bounds — no rounding + req.endSeconds = src.endSeconds; + req.wetDry = 1.0; + req.tailMode = tail.mode; + req.tailMs = tail.manualMs; + req.sampleRate = 0; // follow project rate + req.channelCount = 2; + req.bitDepth = WavBitDepth::Float32; + req.baseName = name.stemBase; + req.displayName = name.label; + req.destination = CaptureDestination::ProjectMedia; + // trackGuids left empty: they exist to stamp provenance onto a Sample this verb + // discards. A multi-track selection is refused inside renderOffline, keyed on the + // render source, so there is no check to add here. + + const CaptureResult res = renderOffline(CaptureScope::Track, src.sourceTracks, req); + if (res.status != CaptureStatus::Ok) { refuse(res.message); return; } + + MediaTrack* source = src.sourceTracks.front(); + ReaProject* proj = EnumProjects(-1, nullptr, 0); + + const int trackCount = CountTracks(proj); + const int srcIndex = indexOfTrack(proj, trackCount, source); + if (srcIndex < 0) { refuse("the source track is no longer in the project"); return; } + + const SiblingPlacement place = + siblingPlacement(folderDepths(proj, trackCount), srcIndex); + + // Read ONCE, and only to reapply the mode / decide whether the result landed + // visible — never to choose a tag. Both tags below are absolute. + const std::string activeMode = session.view().activeModeId(); + + Undo_BeginBlock2(nullptr); + + // flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain + // would process a render that already carries the source's FX a second time. + InsertTrackInProject(proj, place.insertIndex, /*flags=*/0); + MediaTrack* fresh = GetTrack(proj, place.insertIndex); + if (!fresh) { + Undo_EndBlock2(nullptr, "", 0); + refuse("could not create the result track"); + return; + } + + // Both writes or none — one alone lands the new track at the wrong nesting level, + // which is audible in both directions (see siblingPlacement). + if (place.precedingIndex >= 0) { + if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex)) + SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH", + static_cast(place.precedingDepth)); + } + SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH", + static_cast(place.newDepth)); + TrackList_AdjustWindows(false); + + // GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no + // colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour + // and the absence of one, with no branch. + SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR", + static_cast(GetTrackColor(source))); + const std::string freshName = captureTrackName(trackName(source)); + setTrackName(fresh, freshName); + + // Unsnapped and unrounded, deliberately: this placement IS the null test performed + // automatically, so snapping it to the grid would move the audio off the position + // it was rendered from. InsertOptions{} defaults give native length and no conform. + const double cursorPos = GetCursorPosition(); + SetOnlyTrackSelected(fresh); + SetEditCurPos(src.startSeconds, false, false); + // InsertMedia's int return isn't SDK-documented; treated conservatively as + // 0 = failure, matching performArrangeDrop — an empty result track would + // otherwise be a silent no-op, which is exactly what this verb must not produce. + const bool placed = + InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0; + SetEditCurPos(cursorPos, false, false); + // The new track is left selected, alone — in the headline case the source is being + // parked out of sight in the same gesture, so restoring the selection would leave + // the user selecting an invisible track. + + // Absolute, not mode-following: the source parks on the bench, the result is an + // Arrange member whatever mode was active. Explicit records rather than untag(), + // because the record is what the panel's auto-tag detector defers to. + MembershipIndex& membership = session.view().membership(); + membership.tag(guidString(source), kDesignModeId); + membership.tag(guidString(fresh), kArrangeModeId); + + // The track is brand new, so its items are exactly the ones just placed. An + // untagged item would be handed to the detector, which tags to the active mode. + const int itemCount = CountTrackMediaItems(fresh); + for (int i = 0; i < itemCount; ++i) { + if (MediaItem* it = GetTrackMediaItem(fresh, i)) { + const std::string ig = itemGuid(it); + if (!ig.empty()) membership.tag(ig, kArrangeModeId); + } + } + + mintManagedLanes(session.view(), nullptr); + applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch + + Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1); + + // Persist outside the block. The offline render's own save gate already forced a + // saved project, so the Save-As-guarded persist the Design View actions need + // cannot have anything to prompt for here. + session.saveToActiveProject(); + + if (!placed) { + refuse("the render landed at " + res.absolutePath + + " but REAPER refused to place it — the new track is empty."); + return; + } + + // Silent on success — the new track is the feedback. Except when it is not: fired + // outside Arrange the result track is parked, so a silent success would be + // indistinguishable from a no-op. + if (activeMode != kArrangeModeId) { + ShowConsoleMsg(("ReaSampler render in place: created \"" + freshName + + "\" in Arrange (switch to Arrange to see it).\n") + .c_str()); + } +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/render_in_place.h b/src/shell/capture/render_in_place.h new file mode 100644 index 0000000..249e0f5 --- /dev/null +++ b/src/shell/capture/render_in_place.h @@ -0,0 +1,18 @@ +#pragma once +// render_in_place — the third verb: render the selected track's output over the +// current range to the project's recording path, place it on a new sibling track at +// the exact position it was rendered from, and move the source to Design. The bank +// is never read, written, or notified (docs/product/render-in-place.md). + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +// Resolves, renders, creates + dresses the sibling track, places the file, and +// settles both tracks' modes in one undo block. Silent on success (the new track is +// the feedback) except when the result lands invisible; ShowConsoleMsg on refusal. +void RunRenderTrackInPlace(ReaSamplerSession& session); + +} // namespace reasampler::capture diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 8fbd912..7eb5b79 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -5,6 +5,7 @@ // Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp // owns the API pointers). DAW-verified, not unit-tested. +#include #include #include #include @@ -163,11 +164,19 @@ bool detectNewContent() { std::map> trackItemGuids; enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); - const std::vector added = g_panel.contentBaseline.observe(live); + std::vector added = g_panel.contentBaseline.observe(live); if (added.empty()) return false; // first poll after open, or nothing new this tick ViewModeModel& model = g_panel.session->view(); + // An explicit tag wins: this detector classifies content the USER made, not + // content the tool made and already classified. + added.erase(std::remove_if(added.begin(), added.end(), + [&](const std::string& g) { + return model.membership().query(g) != nullptr; + }), + added.end()); + // Which of `added` are items (the manual-lane map keys every item; track GUIDs never // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING // mode set — a drop plus its own new siblings must not count each other as prior. diff --git a/tests/test_capture_name.cpp b/tests/test_capture_name.cpp index 722c45b..da43228 100644 --- a/tests/test_capture_name.cpp +++ b/tests/test_capture_name.cpp @@ -241,6 +241,41 @@ static void testEveryAwkwardStemStaysFilesystemLegal() { } } +// --- captureTrackName ------------------------------------------------------- + +static void testCaptureTrackNamePrefixesAPlainSourceName() { + CHECK(captureTrackName("MONEY") == "Capture MONEY"); + CHECK(captureTrackName("bass di") == "Capture bass di"); +} + +static void testCaptureTrackNameIsIdempotent() { + // The whole point: a second render over a result track must not stack the prefix. + CHECK(captureTrackName("Capture MONEY") == "Capture MONEY"); + CHECK(captureTrackName(captureTrackName("MONEY")) == "Capture MONEY"); + // A fixed point on its own output for EVERY input, degenerate ones included. + for (const char* src : {"MONEY", "", "Capture", "Capture ", "Captured drums"}) { + const std::string once = captureTrackName(src); + CHECK(captureTrackName(once) == once); + } +} + +static void testCaptureTrackNameEmptySourceHasNoTrailingSpace() { + // Unreachable from trackName (GetTrackName always answers "Track N"), so this is + // the defensive case — a bare word rather than a name ending in a space. + CHECK(captureTrackName("") == "Capture"); +} + +static void testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN() { + // trackName's GetTrackName fallback rides in as an ordinary name. + CHECK(captureTrackName("Track 7") == "Capture Track 7"); +} + +static void testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord() { + // "Captured" begins with "Capture" but not with "Capture " — it is a different + // name and must be prefixed like any other. + CHECK(captureTrackName("Captured drums") == "Capture Captured drums"); +} + int main() { testStampIsZeroPaddedMonthDayHourMinute(); testUnsetStampProducesNoDiscriminator(); @@ -268,6 +303,12 @@ int main() { testOrdinalAndMultiSourceCompose(); testEveryAwkwardStemStaysFilesystemLegal(); + testCaptureTrackNamePrefixesAPlainSourceName(); + testCaptureTrackNameIsIdempotent(); + testCaptureTrackNameEmptySourceHasNoTrailingSpace(); + testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN(); + testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord(); + if (g_fail == 0) std::printf("capture_name: all tests passed\n"); else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail); return g_fail ? 1 : 0; diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 3b1b95c..25e9640 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -362,6 +362,39 @@ static void testBankRelativeForNameMatchesDerivePathSpelling() { CHECK(bankRelativeForName(p.fileName) == p.relativePath); } +// --- deriveRenderPaths ------------------------------------------------------ + +static void testRenderPathsSpellTheStemExactlyAsTheBankPathDoes() { + // The one owner claim, made checkable: for the same baseName + uniqueTag, the + // bank path's stem and file name must BE the render path's. If these ever + // diverge, bankRelativeForName's exact-string match against an enumerated + // folder entry starts misfiring and prune misreads referenced files as orphans. + const BankPaths bank = deriveBankPaths("/proj", "kick drum!", "001"); + const RenderPaths render = deriveRenderPaths("/proj/reasampler_bank", + "kick drum!", "001"); + CHECK(render.fileStem == bank.fileStem); + CHECK(render.fileName == bank.fileName); + CHECK(render.absoluteDir == bank.absoluteDir); +} + +static void testRenderPathsTakeTheirDirectoryVerbatim() { + // No bank subfolder is appended — a render outside the bank has none, which is + // what makes "write into the bank folder" inexpressible through this call. + const RenderPaths r = deriveRenderPaths("/proj/media/", "take", ""); + CHECK(r.absoluteDir == normalizeSlashes("/proj/media")); + CHECK(r.fileName == "take.wav"); + CHECK(r.fileStem == "take"); + // Backslashes normalize and a trailing slash is stripped, same as everywhere. + CHECK(deriveRenderPaths("C:\\proj\\media\\", "take", "").absoluteDir == + normalizeSlashes("C:/proj/media")); +} + +static void testRenderPathsEmptyDirectoryStaysEmpty() { + // No CWD fallback: an unresolvable directory must fail at the caller's own + // guard, never silently render next to whatever the process happened to be in. + CHECK(deriveRenderPaths("", "take", "001").absoluteDir.empty()); +} + static void testBankRelativeForNameConventionAndEdge() { // The convention verbatim: "reasampler_bank/" (the one place the spelling lives). CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav"); @@ -396,6 +429,9 @@ int main() { testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps(); testTransitionInPlaceSaveIsNoOp(); testBankRelativeForNameMatchesDerivePathSpelling(); + testRenderPathsSpellTheStemExactlyAsTheBankPathDoes(); + testRenderPathsTakeTheirDirectoryVerbatim(); + testRenderPathsEmptyDirectoryStaysEmpty(); testBankRelativeForNameConventionAndEdge(); if (g_fail == 0) std::printf("capture_paths: all tests passed\n"); diff --git a/tests/test_track_topology.cpp b/tests/test_track_topology.cpp index 3963927..8127056 100644 --- a/tests/test_track_topology.cpp +++ b/tests/test_track_topology.cpp @@ -5,6 +5,7 @@ #include "../src/core/capture/track_topology.h" +#include #include #include @@ -79,6 +80,143 @@ static void testSiblingFolderAfterParentClosesIsNotIncluded() { CHECK(sameIndices(directChildIndices(depths, 0), {1, 2})); } +// --- siblingPlacement ------------------------------------------------------- +// +// Every case asserts the property that actually matters, not just the numbers: the +// new track sits at the SOURCE's own nesting level, and the delta total is +// unchanged so no track after the insertion moves. `levelsAfter` rebuilds the +// post-insertion list and reads the levels straight off it. + +static std::vector depthsAfter(const std::vector& depths, + const SiblingPlacement& p) { + std::vector out = depths; + if (p.precedingIndex >= 0) out[static_cast(p.precedingIndex)] = p.precedingDepth; + out.insert(out.begin() + p.insertIndex, p.newDepth); + return out; +} + +static int sumOf(const std::vector& v) { + int s = 0; + for (int d : v) s += d; + return s; +} + +// Absolute nesting level of track `idx` in a delta list. +static int levelAt(const std::vector& depths, int idx) { + int level = 0; + for (int i = 0; i < idx; ++i) level += depths[static_cast(i)]; + return level; +} + +// The whole contract in one call: the new track is a sibling (same level as the +// source) and nothing downstream shifted (delta total preserved). +static void checkIsSibling(const std::vector& before, int srcIdx) { + const SiblingPlacement p = siblingPlacement(before, srcIdx); + const std::vector after = depthsAfter(before, p); + CHECK(sumOf(after) == sumOf(before)); + CHECK(levelAt(after, p.insertIndex) == levelAt(before, srcIdx)); +} + +static void testSiblingOfANormalTrackGoesDirectlyBelowIt() { + // Three normal tracks at top level; the source is the middle one. + const std::vector depths{0, 0, 0}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingIndex == 1); + CHECK(p.precedingDepth == 0); // unchanged + CHECK(p.newDepth == 0); + checkIsSibling(depths, 1); +} + +static void testSiblingOfAMidFolderTrackStaysInsideTheFolder() { + // 0: parent, 1: child (the source), 2: last child closing the folder. + const std::vector depths{1, 0, -1}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == 0); // still inside; track 2 still closes the folder + checkIsSibling(depths, 1); +} + +static void testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta() { + // The source carries the folder's close, so a naive insert-after would drop the + // new track OUTSIDE the folder and bypass the folder bus entirely. + const std::vector depths{1, -1, 0}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingDepth == 0); // the source no longer closes the folder + CHECK(p.newDepth == -1); // the new track does + checkIsSibling(depths, 1); +} + +static void testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose() { + // 0: outer parent, 1: inner parent, 2: last in BOTH folders (the source). + const std::vector depths{1, 1, -2}; + const SiblingPlacement p = siblingPlacement(depths, 2); + CHECK(p.insertIndex == 3); + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == -2); // the -2 travels intact + checkIsSibling(depths, 2); +} + +static void testSiblingOfAFolderParentLandsAfterTheWholeFolder() { + // Inserting straight after a folder parent would make the new track its FIRST + // CHILD, re-summing the render through the parent's FX and fader. + const std::vector depths{1, 0, -1, 0}; + const SiblingPlacement p = siblingPlacement(depths, 0); + CHECK(p.insertIndex == 3); // past the whole folder, not at index 1 + CHECK(p.precedingIndex == 2); + CHECK(p.precedingDepth == -1); // unchanged — track 2 still closes the folder + CHECK(p.newDepth == 0); + checkIsSibling(depths, 0); +} + +static void testSiblingOfTheLastTrackInTheProjectAppends() { + const std::vector depths{0, 0}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); // == count: appended + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == 0); + checkIsSibling(depths, 1); +} + +static void testSiblingOfTheLastTrackInTheProjectInsideAFolder() { + // The project's last track also closes a folder — the close must still travel. + const std::vector depths{1, -1}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == -1); + checkIsSibling(depths, 1); +} + +static void testMalformedDeltaListClampsRatherThanAsserting() { + // Deltas summing to -3: more closes than opens, which no well-formed project + // produces. The result must still be a legal in-range placement. + const std::vector depths{0, -2, -1}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex >= 0 && p.insertIndex <= static_cast(depths.size())); + CHECK(p.precedingIndex == p.insertIndex - 1); + // Clamped at zero rather than tracking a negative nesting level. + CHECK(levelAt(depthsAfter(depths, p), p.insertIndex) >= 0); + + // An unterminated folder (deltas summing to +1) is the other direction. + const std::vector open{1, 0}; + const SiblingPlacement q = siblingPlacement(open, 1); + CHECK(q.insertIndex == 2); + CHECK(q.newDepth <= 0); // never invents a second folder open +} + +static void testOutOfRangeSourceIndexClamps() { + const std::vector depths{0, 0}; + // Past the end clamps to the last track; negative clamps to the first. + CHECK(siblingPlacement(depths, 99).insertIndex == 2); + CHECK(siblingPlacement(depths, -5).insertIndex == 1); + // An empty project has nothing to precede the new track. + CHECK(siblingPlacement({}, 0).insertIndex == 0); + CHECK(siblingPlacement({}, 0).precedingIndex == -1); +} + int main() { testFlatProjectHasNoChildren(); testFolderParentReturnsItsDirectChildren(); @@ -88,6 +226,16 @@ int main() { testUnterminatedFolderSwallowsTheRest(); testSiblingFolderAfterParentClosesIsNotIncluded(); + testSiblingOfANormalTrackGoesDirectlyBelowIt(); + testSiblingOfAMidFolderTrackStaysInsideTheFolder(); + testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta(); + testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose(); + testSiblingOfAFolderParentLandsAfterTheWholeFolder(); + testSiblingOfTheLastTrackInTheProjectAppends(); + testSiblingOfTheLastTrackInTheProjectInsideAFolder(); + testMalformedDeltaListClampsRatherThanAsserting(); + testOutOfRangeSourceIndexClamps(); + if (g_fail == 0) std::printf("track_topology: all tests passed\n"); return g_fail == 0 ? 0 : 1; } diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index f718198..b69cdd3 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1739,6 +1739,48 @@ static void testLaneMintingEmptyFolderNotSplit() { // -- D2.6 JSON round-trip with lane index + membership ----------------------- +// An EXPLICIT Arrange record is new: the shipped "tag selected tracks -> Arrange" +// action untags instead, so until now Arrange was only ever represented by absence. +// The render-in-place verb writes one, because the record — not the behaviour — is +// what the panel's auto-tag detector defers to. It must be indistinguishable from +// absence everywhere else. +static void testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence() { + ViewModeModel vm; + vm.membership().tag("{TAGGED-ARRANGE}", kArrangeModeId); + // "{UNTAGGED}" is deliberately never tagged — the comparison partner. + + const std::string json = vm.serialize(); + const auto back = ViewModeModel::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == vm); + if (back) CHECK(back->serialize() == json); + + // The record survives as a record, not collapsed away on the round-trip. + if (back) { + const Membership* m = back->membership().query("{TAGGED-ARRANGE}"); + CHECK(m != nullptr); + CHECK(m && m->modeIds == std::set{kArrangeModeId}); + CHECK(back->membership().query("{UNTAGGED}") == nullptr); + } + + // Membership answers identically for the record and for its absence, in BOTH + // modes — that equivalence is what makes writing the record free of behaviour. + const auto checkEquivalent = [](const ViewModeModel& m) { + CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId) == + m.leafBelongsToMode("{UNTAGGED}", kArrangeModeId)); + CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId)); + CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId) == + m.leafBelongsToMode("{UNTAGGED}", kDesignModeId)); + CHECK(!m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId)); + }; + checkEquivalent(vm); + if (back) checkEquivalent(*back); // and after a save/reload round-trip + + // untag() still returns it to absence, so the existing way out still works. + CHECK(vm.membership().untag("{TAGGED-ARRANGE}")); + CHECK(vm.membership().query("{TAGGED-ARRANGE}") == nullptr); +} + static void testLaneJsonRoundTrip() { ViewModeModel vm; CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); @@ -1922,6 +1964,7 @@ int main() { testLaneMintingShowBothNotForceSplit(); testLaneMintingSingleModeLeafVisibleOnceNoSplit(); testLaneMintingEmptyFolderNotSplit(); + testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence(); testLaneJsonRoundTrip(); testLaneMalformedJson(); From d7e5c59547a76a1ebc0c8c7fedaa9368431464da Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 14:38:35 -0400 Subject: [PATCH 04/48] Remediate Phase P render-in-place review findings Fix the ProjectMedia refusal path's false bank claims and file relocation, an unreachable-undo idiom, and eight comment/doc accuracy issues. --- src/core/capture/capture_name.cpp | 6 +++-- src/core/capture/capture_name.h | 12 +++++++++- src/core/capture/track_topology.h | 6 +++-- src/core/view/CLAUDE.md | 4 ++-- src/shell/capture/CLAUDE.md | 4 ++-- src/shell/capture/capture.cpp | 2 +- src/shell/capture/render_bounds_gate.cpp | 28 ++++++++++++++++++++---- src/shell/capture/render_bounds_gate.h | 20 ++++++++++------- src/shell/capture/render_in_place.cpp | 22 +++++++++++++------ 9 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/core/capture/capture_name.cpp b/src/core/capture/capture_name.cpp index 9126cc3..65209b1 100644 --- a/src/core/capture/capture_name.cpp +++ b/src/core/capture/capture_name.cpp @@ -90,8 +90,10 @@ std::string captureTrackName(const std::string& sourceName) { const std::string prefix(kCaptureTrackPrefix); // A source with no readable name yields the bare word rather than a trailing // space; both spellings are fixed points, which is what makes the whole function - // one (a track named exactly "Capture" must not become "Capture Capture"). - const std::string bare = prefix.substr(0, prefix.size() - 1); + // one (a track named exactly "Capture" must not become "Capture Capture"). Read + // from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names + // can't drift out of sync with each other (both expand from the same header token). + const std::string bare = kCaptureTrackPrefixBare; if (sourceName.empty()) return bare; if (sourceName == bare) return sourceName; diff --git a/src/core/capture/capture_name.h b/src/core/capture/capture_name.h index 1e7133b..0225608 100644 --- a/src/core/capture/capture_name.h +++ b/src/core/capture/capture_name.h @@ -59,10 +59,20 @@ std::string formatCaptureStamp(const CaptureStamp& stamp); CaptureName composeCaptureName(const CaptureNameInputs& in); +// The single source of truth for the word itself — kCaptureTrackPrefixBare and +// kCaptureTrackPrefix below both expand from this one token, so editing it can never +// desync captureTrackName's "no readable source name" bare-word fallback from the +// separator-terminated prefix it is derived from. +#define REASAMPLER_CAPTURE_TRACK_WORD "Capture" + +// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's +// no-readable-source-name fallback. +inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD; + // Prefixed onto a source track's name to name the track a render-in-place created. // A display convention, not a persisted key — unlike a lane prefix or an action-id // suffix, changing it later strands nothing. -inline constexpr const char* kCaptureTrackPrefix = "Capture "; +inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " "; // The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on // its own output, so a second render over a result track yields "Capture MONEY" diff --git a/src/core/capture/track_topology.h b/src/core/capture/track_topology.h index 96cc567..9903e47 100644 --- a/src/core/capture/track_topology.h +++ b/src/core/capture/track_topology.h @@ -43,8 +43,10 @@ struct SiblingPlacement { // Levels are absolute nesting depths recovered from the deltas (level[0] = 0, // level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first // following track back at the source's own level — i.e. after the whole folder; -// everything else inserts directly below the source. The two writes preserve the -// total delta sum, so no track after the insertion changes level. +// everything else inserts directly below the source. On a well-formed delta list +// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no +// track after the insertion changes level — the malformed case below does not carry +// that guarantee; the clamp keeps the result legal, not level-preserving. // // A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to // the nearest legal placement rather than asserting: the failure mode of a corrupt diff --git a/src/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index e3a64b1..57b5e93 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -64,8 +64,8 @@ settled 2026-07-23): - **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane play/show so only the active mode's lane is present. Items keep their real position and real track — nothing is moved in time or deleted. -- **Membership: adoption rule for new items; active mode for new tracks.** New - tracks are tagged to the active mode at creation **only when the GUID carries no +- **Membership: adoption rule for new items; active mode for new tracks absent an + explicit tag.** New tracks are tagged to the active mode at creation **only when the GUID carries no membership record** — an explicit tag wins over the detector, because the detector classifies content the *user* made, not content the tool made and already classified. New items follow an diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 94fb9d2..cdfa9e9 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -55,12 +55,12 @@ detail not covered there: ## Modules -- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds. +- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds. - `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened). - `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`. - `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`. - `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`). -- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). +- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely). - `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. - `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked. - `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 4fdf6e4..6585ed7 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -561,7 +561,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check, // Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0). const BoundsVerdict emptyVerdict = - checkRenderedFileNotEmpty(expectedPath, projectDir); + checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination); if (emptyVerdict.refused) { result.status = CaptureStatus::BoundsMismatch; result.message = emptyVerdict.message; diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index c0b7311..b67dc4b 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -45,6 +45,25 @@ std::string retainRefusedRender(const std::string& renderedPath, renderedPath + ", indexed by nothing. Delete it when done."; } +// ProjectMedia is the project's own media, never the bank's (docs/product/render-in-place.md +// "Where the file goes") -- a refusal takes no custody of it. No move, no bank folder, no +// mention of a bank the render was never headed for. +std::string leaveRefusedRenderInPlace(const std::string& renderedPath) { + return " The render was left where it was written, at " + renderedPath + + " -- delete it when done."; +} + +// Dispatches the refusal's file-handling sentence by destination, so both verdict +// functions below state one true thing about the file rather than the bank sentence +// on every destination. +std::string refusalOutcome(const std::string& renderedPath, + const std::string& projectDir, + CaptureDestination destination) { + if (destination == CaptureDestination::ProjectMedia) + return leaveRefusedRenderInPlace(renderedPath); + return retainRefusedRender(renderedPath, projectDir); +} + } // namespace std::string refusedRenderFolder(const std::string& projectDir) { @@ -52,7 +71,8 @@ std::string refusedRenderFolder(const std::string& projectDir) { } BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, - const std::string& projectDir) { + const std::string& projectDir, + CaptureDestination destination) { BoundsVerdict v; std::error_code ec; const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec); @@ -61,7 +81,7 @@ BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, v.refused = true; v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty " "file, so there is nothing to check the requested range against." + - retainRefusedRender(renderedPath, projectDir); + refusalOutcome(renderedPath, projectDir, destination); return v; } @@ -89,7 +109,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, "not be read (locked, missing, or a permissions error), its WAV " "header did not parse, or it declared no sample rate -- so the " "frames it holds were never checked against the requested range." + - source + retainRefusedRender(renderedPath, projectDir); + source + refusalOutcome(renderedPath, projectDir, request.destination); return v; } @@ -124,7 +144,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, std::to_string(request.endSeconds) + "s) -> frame indices [" + std::to_string(std::llround(request.startSeconds * rate)) + ", " + std::to_string(std::llround(request.endSeconds * rate)) + ")." + - msNote + retainRefusedRender(renderedPath, projectDir); + msNote + refusalOutcome(renderedPath, projectDir, request.destination); return v; } diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index 27a4eb1..55a3433 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -10,9 +10,11 @@ namespace reasampler::capture { -// A refused render is MOVED out of the bank, not deleted: while the root cause of a -// short render is open (docs/TODO.md), the frames it did print are the evidence — and -// nothing may index a file the bank never accepted. +// On the Bank destination, a refused render is MOVED out of the bank, not deleted: while +// the root cause of a short render is open (docs/TODO.md), the frames it did print are +// the evidence — and nothing may index a file the bank never accepted. On ProjectMedia, +// the render is the project's own media (docs/product/render-in-place.md "Where the file +// goes"), so a refusal leaves it exactly where it was written — no move, no bank folder. struct BoundsVerdict { bool refused = false; std::string message; // console text; meaningful only when refused @@ -22,7 +24,8 @@ struct BoundsVerdict { // frame count is not the window's (render_window::renderHonoredBounds owns the // tolerance and its limits), or the file cannot be measured at all — an unmeasured // render is not a verified one. TailMode::Auto/Manual add frames by design and are -// never judged here. `projectDir` is where a refused render is parked. +// never judged here. `projectDir` and `request.destination` together decide where a +// refused render is parked. BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const std::string& projectDir, const CaptureRequest& request); @@ -31,11 +34,12 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, // never legitimately produce zero), independent of and ahead of the TailMode::None-only // gate above, which does not run on Auto/Manual at all. BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, - const std::string& projectDir); + const std::string& projectDir, + CaptureDestination destination); -// Where a refused render is retained -- exposed so a multi-unit caller (batch capture) -// can name the folder once without duplicating the subfolder name `checkRenderedBounds` -// and `checkRenderedFileNotEmpty` already use internally. +// Where a refused Bank-destination render is retained -- exposed so a multi-unit caller +// (batch capture, Bank-only) can name the folder once without duplicating the subfolder +// name `checkRenderedBounds` and `checkRenderedFileNotEmpty` already use internally. std::string refusedRenderFolder(const std::string& projectDir); } // namespace reasampler::capture diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp index dd53752..cbe0da0 100644 --- a/src/shell/capture/render_in_place.cpp +++ b/src/shell/capture/render_in_place.cpp @@ -2,9 +2,6 @@ // // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the // one TU that defines the API pointers; here they are extern. -// -// Traffic is one-way: this borrows capture's render, and capture may never borrow -// this placement back. #include "shell/capture/render_in_place.h" @@ -121,7 +118,11 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { const int trackCount = CountTracks(proj); const int srcIndex = indexOfTrack(proj, trackCount, source); - if (srcIndex < 0) { refuse("the source track is no longer in the project"); return; } + if (srcIndex < 0) { + refuse("the source track is no longer in the project; the render landed at " + + res.absolutePath + " but was not placed."); + return; + } const SiblingPlacement place = siblingPlacement(folderDepths(proj, trackCount), srcIndex); @@ -137,8 +138,13 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { InsertTrackInProject(proj, place.insertIndex, /*flags=*/0); MediaTrack* fresh = GetTrack(proj, place.insertIndex); if (!fresh) { - Undo_EndBlock2(nullptr, "", 0); - refuse("could not create the result track"); + // InsertTrackInProject already mutated the project by this point, so the + // "no ext-state write -> discard" idiom does not apply here — a discard would + // leave the orphaned track un-undoable. + Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)", + -1); + refuse("could not create the result track; the render landed at " + + res.absolutePath + " but was not placed."); return; } @@ -151,7 +157,6 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { } SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH", static_cast(place.newDepth)); - TrackList_AdjustWindows(false); // GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no // colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour @@ -161,6 +166,9 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { const std::string freshName = captureTrackName(trackName(source)); setTrackName(fresh, freshName); + // After every attribute write, per the SDK header's manual-panel-update caveat. + TrackList_AdjustWindows(false); + // Unsnapped and unrounded, deliberately: this placement IS the null test performed // automatically, so snapping it to the grid would move the audio off the position // it was rendered from. InsertOptions{} defaults give native length and no conform. From 7c43e554359bdc5e6ea86add482f0c15d7bbe2c6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 14:54:58 -0400 Subject: [PATCH 05/48] docs: make render_bounds_gate bullet destination-aware Phase P split Bank-move vs ProjectMedia-leave-in-place behavior in render_bounds_gate.h but missed updating this CLAUDE.md bullet. --- src/shell/capture/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index cdfa9e9..9814735 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -56,7 +56,7 @@ detail not covered there: ## Modules - `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds. -- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened). +- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). Refusal handling is destination-aware (`render_bounds_gate.h`): on `CaptureDestination::Bank`, a refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened); the bank never INDEXES it either way. On `CaptureDestination::ProjectMedia` the file is left exactly where the renderer wrote it — no move, no bank folder, no bank language in the message — because that render is the project's own media, not the tool's (`docs/product/render-in-place.md` "Where the file goes"). - `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`. - `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`. - `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`). From 51b13304ee349de62fcd195b40793d61494e75d2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 15:04:19 -0400 Subject: [PATCH 06/48] docs: retire Phase Rho from the plan, record it as landed --- docs/COMPLETED.md | 55 +++++++ docs/PLAN.md | 375 +--------------------------------------------- 2 files changed, 61 insertions(+), 369 deletions(-) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index e50d580..f7f86bc 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -887,3 +887,58 @@ wrong that refusal costs a working capture. Each track's DAW-verification obliga is recorded in `docs/PLAN.md`'s Phase Ψ section; `docs/verify-track-scope-multitrack.md` is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track refusal specifically. No human has observed any of these seven behaviors in a DAW. + +### Phase Ρ — Render in place: a track's output to a new sibling, source to the bench + +One wave, one track (Ρ-W1-T1 `render-in-place`), code-complete, reviewed, remediated, and +merged to `dev` as `b400384`: 91/91 tests passing, a clean build. Phase Ρ came from a +direct request (Daniel, 2026-08-02) rather than a backing product doc list — see +`docs/product/render-in-place.md` for the framing and its three [Daniel]-class forks +(Ρ-F1/F2/F3), all ruled the day the phase was framed. + +**Ρ-W1-T1 — `render-in-place`.** One bindable action, `RENDER_TRACK_IN_PLACE`, renders +the selected track's output over the current range to the project's recording path — +never the bank — places it as an item on a brand-new sibling track at the exact unsnapped +render position, clones the source's colour and its name with an idempotent `Capture ` +prefix, moves the source track to Design mode, and puts the result track into Arrange +unconditionally (the Ρ-F2 ruling). New `src/shell/capture/render_in_place.{h,cpp}`. +Extended `core/capture/track_topology` (`siblingPlacement`), `core/capture/capture_name` +(`captureTrackName`), `core/capture/capture_paths` (`RenderPaths`/`deriveRenderPaths`, +with `deriveBankPaths` re-expressed over it). A `CaptureDestination` enum was added to +`CaptureRequest`; `render_bounds_gate` became destination-aware. A filter added to +`panel_input::detectNewContent`, one `ActionTableRow` in `src/app/main.cpp`. All four +invariant amendments the plan required (`src/shell/capture/CLAUDE.md`, +`src/shell/actions/CLAUDE.md`, root `CLAUDE.md` §"The load-bearing principle", +`src/core/view/CLAUDE.md`) landed inline with the track. + +**Four deviations worth recording:** + +1. **The `activeModeId` acceptance criterion was met in spirit, not to the letter.** The + criterion said `activeModeId()` must appear only in the `applyMode` reapply. The + implementer added the spec-recommended one-line Design-fired `ShowConsoleMsg`, which + requires reading the active mode, and hoisted that read into a single named local + shared by the message condition and the reapply. All three `tag()` calls still take + literal mode ids, so the ruling the criterion protects (Ρ-F2) holds. Review accepted + this explicitly. +2. **The `panel_input` edit was larger than the spec's estimate** — the spec budgeted + "two lines only"; the landed change is six lines plus an `` include and + dropping a `const`, still confined to `detectNewContent`. +3. **`TrackList_AdjustWindows(false)` was included preemptively** where the spec had + asked to `[verify — DAW]` whether it is needed. Consequence worth recording: the DAW + check can no longer distinguish, so answering that question now requires commenting + the call out locally. +4. **A behavioural change beyond Ρ's stated scope**, surfaced in review and judged an + improvement: a track restored by undo now keeps its original mode instead of being + re-tagged to the active mode. Its reach is narrower than it sounds — + `ViewModeModel::reconcile` prunes records for GUIDs that have gone away, so a track + absent across a reconcile pass still falls back to the old behaviour. + +Both **[propose at review]** items resolved to the plan's own recommendations: the +Design-fired console message was added (yes), and no master-track refusal was added +(no — `ResolveScopeSource` already refuses a master-only selection). + +**The entire DAW-verification obligation remains outstanding.** The null test on Ρ's +own output, the three folder cases, collapsed-mono placement and summing, both mode +transitions waited out past a panel timer tick, undo, name/colour clone, and +`GetProjectPathEx` against a non-default recording path — none of it is unit-testable +and none has been run. diff --git a/docs/PLAN.md b/docs/PLAN.md index 3a2db48..0833a9a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -91,8 +91,9 @@ decision.** RULED**, same day (Daniel, 2026-08-02): **Ρ-F1** multi-track — *"refuse"*, one track per fire, a settled non-goal rather than a deferral; **Ρ-F2** the result track's mode — *"for this action which is not a capture, the result track should always go to -arrange"*; **Ρ-F3** tail — *"follow panel tail settings."* The rulings are folded into -the track below and indexed at `docs/product/render-in-place.md` §"Rulings". **Ρ-F2 +arrange"*; **Ρ-F3** tail — *"follow panel tail settings."* Ρ-W1-T1 has landed; see +`docs/COMPLETED.md` for the full narrative, and the rulings remain indexed at +`docs/product/render-in-place.md` §"Rulings". **Ρ-F2 overrode the request's own original wording** ("stays in whatever mode was active") and is the only one of the three that changed the spec: the result track is now an Arrange member unconditionally, the A/B-on-the-bench behaviour mode-following would have enabled @@ -2891,372 +2892,6 @@ properties under test are structural and a large payload proves nothing extra. --- -## Phase Ρ — Render in place: a track's output to a new sibling, source to the bench - -**Ships:** one bindable action that renders the selected track's output over the current -range to a file outside the bank, drops that file as an item on a brand-new sibling track -at the exact position it was rendered from, clones the source's colour and its name with an -idempotent `Capture ` prefix, moves the source track into Design mode — where Design -View's existing park hides it, takes it out of the mix, and puts its FX offline — and puts -the result track into Arrange, unconditionally. The bank is never read, never written, and -never notified. - -**Consolidates: none of the seventeen.** Phase Ρ came from a direct request (Daniel, -2026-08-02) and is scoped in `docs/product/render-in-place.md`. Daniel's framing, verbatim -in substance: *similar to REAPER's "Render selected track time selection to new track -(stereo) and mute original", except instead of muting the original, the source track -stays/goes to design mode, and the resulting new sibling track — which gets the rendered -audio item placed correctly in the timeline — stays in whatever mode was active when the -action was run; the new track clones the source track colour and name with a Capture -prefix; this must NOT put the rendered audio into the ReaSampler banks/pool.* **The -mode-following clause in that framing was superseded by Daniel's own Ρ-F2 ruling the same -day** — *"for this action which is not a capture, the result track should always go to -arrange"* — and the quote is kept verbatim only as the record of the request. It -**supersedes nothing** — a sweep of `docs/TODO.md` and `docs/TODO-1.0.md` for -`render.in.place|render to new track|preserve.source` returns nothing. - -### The framing answer — why this does not breach the load-bearing principle - -Root `CLAUDE.md`'s rule — *capture and placement are separate acts; any code path that -auto-inserts a capture into the timeline must be rejected in review* — is a rule **about -the bank**, and Phase Ρ does not put the bank on either side of its verb. The full argument -is `docs/product/render-in-place.md` §"The third verb"; the operative summary, which every -review of this phase must apply: - -| Verb | Source | Sink | Touches the bank | -|---|---|---|---| -| **Capture** (`RunCapture`, batch, realtime, bake, ingest) | arrange / instrument | bank | writes it | -| **Placement** (`RunInsertSelected`, `performArrangeDrop`) | bank | arrange | reads it | -| **Render in place** (this phase) | arrange | arrange | never | - -What Ρ shares with capture is the **render** — `renderOffline`, `FxBypassGuard`, exact -custom time bounds, `RENDER_ADDTOPROJ = 0`, the multi-track refusal — not the capture. A -capture is a render *plus* a bank landing; Ρ takes the mechanism and declines the landing. - -**Four boundary conditions, three of them structural, all review-rejectable:** - -1. Ρ's shell never calls `session.bank()`, `session.book()`, `session.recordCreated()`, or - `session.bumpBankGeneration()`. The `Sample` the backend returns is discarded, and on the - `ProjectMedia` destination its `relativePath` is left **empty** — a Ρ `Sample` is inert - by construction. -2. **Ρ cannot express "write into the bank folder."** The destination reaches the backend as - a two-valued enum, never a caller-supplied path string. A `renderDir` string on - `CaptureRequest` instead of the enum **is** the drift, and is rejected on sight. -3. Ρ's file is never recorded as owned, so prune (`(owned ∩ present) − referenced`) cannot - reach it — and it lives outside the bank folder, so prune's enumeration never sees it - either. Two independent layers. The tool deletes only what it owns; a render-in-place - file belongs to the project. -4. **Traffic is one-way.** Ρ may borrow capture's render; **capture may never borrow Ρ's - placement.** No `place` flag on `CaptureActionDef`, no "capture and also place" action, - ever. - -### Phase-Ρ acceptance criteria - -These bind the track in this phase, in addition to the plan-wide set above. - -- **Placement is sample-exact and unsnapped.** The item lands at the render window's - `startSeconds`, unrounded, with `SnapToGrid` deliberately **not** applied (unlike - `performArrangeDrop`). Ρ's placement is the null test performed automatically — a render - of a range re-inserted at its source position nulls to silence against the source — so a - snapped or rounded placement is a phase failure, not a rough edge. -- **No tempo conform, ever.** `computeInsertMode(InsertOptions{})` only; `insert_plan` - already guarantees the &4 stretch-to-time-selection bit is never set. No conform variant - of this action is offered. -- **The bank path is byte-identical to today.** The `CaptureDestination` enum defaults to - `Bank`; every existing capture entry point must produce exactly the file, path, hash, and - index entry it produces now. If any capture test changes expectation, the seam is wrong. -- **The new track is bare.** `InsertTrackInProject(proj, p, /*flags=*/0)` — flags&1 adds - default envelopes/FX (SDK header 3954) and a default chain would process the render a - second time. -- **Both `I_FOLDERDEPTH` writes, or none.** The sibling-placement arithmetic is pure and - unit-tested before any DAW work; a render that lands the new track at the wrong nesting - level is audibly wrong in both directions (double-processed through a folder it re-enters, - or bypassing the folder bus entirely). -- **The Ρ-F2 ruling survives the auto-tag detector.** The result track and its placed - item are tagged `kArrangeModeId` **explicitly** (a membership record, not an `untag()` - to the Arrange default), and `panel_input::detectNewContent` drops added GUIDs that - already carry a record. Without that filter the detector tags every new track to the - active mode on its next tick and a Design-fired render silently becomes a Design - member — the ruling reversed inside a second. This criterion **replaces** the - tag-before-reapply ordering criterion the phase carried under mode-following: with the - result track an Arrange member unconditionally, a Design reapply parking it is the - correct outcome and the ordering is state hygiene, not behaviour. -- **One undo block, `UNDO_STATE_ALL`,** opened before the track is created and closed after - the mode reapply; the render sits outside it. `persistViewState` runs **after** the block - closes — it may raise a Save-As dialog, which must not sit inside an open undo block - (`design_view_actions::doMoveItems`' documented ordering). -- **Every pure module gets a `_tests` target.** All three pure additions land in - existing `core/capture` modules that already have one. - -**Performance posture.** Every surface is cold — one gesture, once. None of the named hot -paths (peaks envelope compute, audition, the realtime-capture tick's single-pointer-test -idle fast path, the instrument's `process()`) is touched. - -**Concurrency.** Phase Ρ is extension-side. Γ lives in `core/instrument/` + -`shell/instrument/`; Ε lands in the new `core/package/` + `shell/package/`. The pre-existing -files Ρ edits are named in its track's surface boundary and intersect neither — including -`shell/panel/panel_input.cpp`, which the Ρ-F2 ruling adds: no in-flight track in this plan -touches that file (Phase Ψ's two named regions in it, the footer block and the drag-arm -block, are both landed, and both are functions other than `detectNewContent`). - -### Rulings — Daniel's, 2026-08-02. Nothing open. - -All three forks this phase opened were ruled the day it was framed. Full statements, the -counter-arguments that made each a fork, and the one unexercised alternative: -`docs/product/render-in-place.md` §"Rulings". - -- **Ρ-F1 — multi-track. RULED: refuse.** One selected track per fire, inherited from - `isMultiTrackStemRender`. A **settled non-goal**, in the same register as the other - entries under §"What Phase Ρ explicitly is NOT" — not deferred-with-a-plan. There is no - per-track loop planned, no second wave holding one, and no seam to leave half-open for - it. Matches the framing recommendation; nothing in the track changed. -- **Ρ-F2 — the result track's mode. RULED: always Arrange.** *"For this action which is - not a capture, the result track should always go to arrange."* **This overrode the - framing and the request's own original wording** — the result track no longer follows - the active mode. The source still goes to Design. Three consequences, all specced - below: the result track and its item are tagged `kArrangeModeId` explicitly; firing - from Design produces **no visible change** (the A/B-on-the-bench behaviour - mode-following would have enabled **does not exist** and must not be cited as a - benefit anywhere); and the panel's auto-tag detector needs a two-line - explicit-tag-wins filter, or it re-tags the result track to Design on its next timer - tick and silently reverses the ruling. -- **Ρ-F3 — tail. RULED: follow the panel tail settings.** Matches the framing - recommendation; nothing in the track changed. **Two consequences, accepted rather than - caveated:** under Auto/Manual the placed item is **longer than the window it replaces** - (correct for a decaying chain, wrong for a butt-joined section — the user's lever is - the panel's own tail setting), and the exact-bounds gate is **inactive** in those two - modes because it runs only under `TailMode::None`. Both are inherited from every other - capture path, not introduced here. The item's **start** is exact in all three modes, so - the null test holds in all three. The third option floated at framing (run the gate's - start-alignment check regardless of tail mode) is **not ruled in** and is recorded in - the product doc as an unexercised alternative. - ---- - -### Ρ-W1 — The verb - -**Depends on:** nothing. - -**One track, deliberately.** The whole phase is roughly 350 lines: three small pure -additions to existing `core/capture` modules, one bounded edit to the offline backend, one -new shell TU, a two-line filter in `panel_input.cpp`, one `ActionTableRow`, four invariant -amendments. Splitting it would create a -merge dance across `core/capture` for no gain, and the pure half cannot be reviewed -meaningfully apart from the caller that gives it meaning. **Named contingency:** if the -sibling-placement arithmetic balloons in implementation, that function is the natural split -point — it is the only piece with zero dependency on anything else in the track. - -#### Ρ-W1-T1 — `render-in-place` - -**Goal.** One bindable action: render the selected track's output over the current range to -the project's recording path, place it on a new sibling track at the exact render position, -clone colour and name, move the source to Design and the result track to Arrange — -**always Arrange, whatever mode was active** (Ρ-F2). - -**Spec:** `docs/product/render-in-place.md` — §"The render", §"Where the file goes", -§"Placement", §"The new track", §"Mode transitions", §"Undo", §"The action". - -**Surface boundary — owns:** -- **New:** `src/shell/capture/render_in_place.{h,cpp}` (the action body) and its - `CMakeLists.txt` entry. It lives in `shell/capture/` rather than `shell/actions/` because - it composes `renderOffline` and `ResolveScopeSource` — that directory's `CLAUDE.md` places - action *bodies* here and reserves `shell/actions` for skins over mutation logic owned - elsewhere. -- **Extends (existing modules, existing test targets):** `core/capture/track_topology` - (`siblingPlacement`), `core/capture/capture_name` (`captureTrackName`), - `core/capture/capture_paths` (`RenderPaths` + `deriveRenderPaths`, with `deriveBankPaths` - re-expressed over it so the file-stem spelling keeps one owner — - `bankRelativeForName` already depends on that). -- **Edits, bounded:** `src/shell/capture/capture.{h,cpp}` — the `CaptureDestination` enum on - `CaptureRequest`, the ~6-line destination branch at path derivation, and - `CaptureResult::absolutePath`. `src/shell/panel/panel_input.cpp` — two lines inside - `detectNewContent` only, dropping added GUIDs that already carry a membership record - (`MembershipIndex::query(guid) != nullptr`) before the `autoTagNewContent` call. That - edit exists **only because of the Ρ-F2 ruling**. `src/app/main.cpp` — one - `ActionTableRow`. -- **Does not own:** anything under `core/instrument/`, `shell/instrument/`, `core/package/`, - `shell/package/`, `core/model/`, `core/tracking/`, `core/reclaim/`, or `shell/persist/`. - No new directory. No new persisted state, no new ext-state key, no new wire version rung. - -**Behavior.** -- **Resolve** via `ResolveScopeSource(CaptureScope::Track, …)` — razor-else-time range, - selected tracks, canonical GUIDs, source track name. No range → refuse with the reason - `resolveRange` produced. **Item extent is not a fallback and must not become one.** -- **Refuse multi-track by inheritance.** `renderOffline` fires `isMultiTrackStemRender` - before touching anything, keyed on the render *source* (`SelectedTracks`), so Ρ adds no - check of its own and gets `multiTrackRefusalMessage(CaptureScope::Track)` for free. -- **Render** through `renderOffline(CaptureScope::Track, src.sourceTracks, req)` with - `req.destination = ProjectMedia`, tail from `bankPanelTailSetting()`, `channelCount = 2`, - `Float32`, `sampleRate = 0`, and the name from `captureNameFor(src.trackNames, 0, - "capture")`. Inherits the FX-bypass guard, the render-selection guard, the `RENDER_*` - snapshot/restore, `RENDER_ADDTOPROJ = 0`, the exact-bounds gate, the unsaved-project - Save-As gate, and the lossless mono collapse — **all unchanged**. -- **Destination resolves in the backend, after its own save gate**, so an unsaved project is - still prompted before any path arithmetic runs. `ProjectMedia` → `GetProjectPathEx(proj, - …)` (SDK header 2550; header 3102 names it as the way to get the *effective* recording - path when `RECORD_PATH` is blank or relative). The relative-paths-only invariant is - untouched — it binds the `BankIndex`, and Ρ writes to no index. -- **Sibling placement** (pure, in `track_topology`): prefix-sum `I_FOLDERDEPTH` to absolute - levels; `L = level[srcIdx]`; if `depth[srcIdx] >= 1` the insert position `p` is the first - `j > srcIdx` with `level[j] == L` (else `count`), otherwise `p = srcIdx + 1`; then exactly - two writes — `depth[p-1] = L - level[p-1]` and the new track's `depth = level[p] - L` - (with `level[count] = 0`). Total delta sum preserved, so nothing downstream shifts. The - five cases and their expected outcomes are tabulated in the product doc; a malformed - project whose deltas do not sum to zero clamps rather than asserts. -- **Create + dress:** `InsertTrackInProject(proj, p, 0)`, `GetTrack(proj, p)`; - `SetMediaTrackInfo_Value(new, "I_CUSTOMCOLOR", (double)GetTrackColor(source))` — one line - clones a colour and the absence of one, since `GetTrackColor` returns the value already - OR'd with `0x1000000` and `0` means unset; `GetSetMediaTrackInfo_String(new, "P_NAME", - buf, true)` with `captureTrackName(trackName(source))`. -- **Name rule (pure, tested):** `"Capture " + sourceName`, **idempotent** — if the source - name already begins with the prefix, the new name is the source name verbatim, so a second - run yields `Capture MONEY`, never `Capture Capture MONEY`. An unnamed source yields - `Capture Track N` (`trackName` uses `GetTrackName`, which already answers REAPER's own - convention — the Ψ-W2-T1 precedent). A counter suffix is rejected: REAPER does not - uniquify track names either, and it is a treadmill. -- **Place:** snapshot the edit cursor → `SetOnlyTrackSelected(new)` → - `SetEditCurPos(src.startSeconds, false, false)` → `InsertMedia(absolutePath, - computeInsertMode(InsertOptions{}))` → restore the cursor. **Leave the new track selected, - alone** — a deliberate divergence from the restore-the-selection convention every other - placing path follows, because in the headline case the source is being parked out of sight - in the same gesture and restoring the selection would leave the user selecting an - invisible track. -- **Modes (Ρ-F2, RULED — absolute, not mode-following):** - `membership().tag(sourceGuid, kDesignModeId)` (covers stays *and* goes — `tag` replaces - prior single-mode membership); `membership().tag(newTrackGuid, kArrangeModeId)` — - **`kArrangeModeId` unconditionally, never `view.activeModeId()`**; `membership().tag(…, - kArrangeModeId)` for **each item on the new track** (enumerate after `InsertMedia`; the - track is brand new so those are exactly the items just placed — `item_read::itemGuid` is - the existing GUID seam); then `mintManagedLanes(view, nullptr)`; then `applyMode(view, - view.activeModeId(), nullptr)` — a reapply, never a switch, so it is not transport-gated - and does not touch solo. -- **Why explicit tags and not `untag()`:** an untagged GUID is an Arrange member by - behaviour but carries no membership record, and the record is what the auto-tag - detector's new filter keys on. Tag, don't untag. **This is the first explicit - `kArrangeModeId` record in the tree** — the shipped *tag selected tracks → Arrange* - action dispatches to `doUntag()`, i.e. Arrange-by-absence. The record is well-formed - and behaviourally identical (`isMember` answers the same for both states, `untag()` - still clears it); it costs one persisted entry. Unit-test the JSON round-trip; - `[verify — DAW]` that such a project shows no view-behaviour difference. -- **Fired from Design, the result track is parked and nothing is visible.** That is the - ruled behaviour, not a bug: the result track is an Arrange member, so a Design reapply - parks it, and it appears in the source's place on the next switch to Arrange. The - tag-before-reapply ordering that mode-following made load-bearing is now state hygiene - only. -- **Selection stays absolute too.** The result track is left selected, alone, in both - cases — even fired from Design, where that selects a parked track. Making selection - conditional on the active mode would reintroduce exactly the mode-relative behaviour - Ρ-F2 removed. -- **Inherited and deliberately not fought:** show-both on the source is not cleared (it is - the user's pin); a folder-parent source is not hidden by tagging, because parents are - derived (`core/view/CLAUDE.md`) — the existing tag action behaves identically. **Do not - invent a cascade that tags the children.** -- **Feedback:** silent on success (the new track is the feedback), `ShowConsoleMsg` on every - refusal — matching `RunInsertSelected`. **[propose at review]** whether the Design-fired - path breaks that silence with a one-line `ShowConsoleMsg` naming the track it created: - under Ρ-F2 that path produces no visible change, so a silent success is - indistinguishable from a no-op. Recommendation: yes, one line and one string. -- **Action:** suffix **`RENDER_TRACK_IN_PLACE`** — FOREVER-STABLE per channel, minted as a - new `RENDER_*` verb family rather than a `CAPTURE_*` member, deliberately: the id is - permanent and is the most durable statement the codebase makes about which pillar a - feature belongs to. Phrase: **`"render selected track to a new track (source moves to - Design)"`**. Main section only; one `ActionTableRow`; no `custom_action`/`hookcommand2`. - -**Invariant amendments — deliverables of this track, not follow-ups** (the Phase Ψ -precedent, where three such amendments were acceptance criteria of the tracks that broke -them). A track that lands Ρ without these reads as an invariant breach in review. - -1. `src/shell/capture/CLAUDE.md` §Invariants — *"`RunInsertSelected` is the one deliberate - exception to capture-never-places."* Amend to state that this directory now hosts two - placing paths and give the discriminator: `RunInsertSelected` places a *bank sample*; - `render_in_place` places a render that never entered the bank. Neither is a capture - placing itself. -2. `src/shell/actions/CLAUDE.md` §Invariants — *"`arrange_drop_win` is the only - timeline-placing shell in this directory."* Scope the sentence explicitly to that - directory and cross-reference the third verb. -3. Root `CLAUDE.md` §"The load-bearing principle" — **one sentence, not a rewrite**: a - render that never enters the bank and never leaves it is a third verb outside the rule, - with the two-way boundary named. The prohibition must not be softened; the exception must - be named precisely. -4. `src/core/view/CLAUDE.md` §Invariants — *"New tracks are tagged to the active mode at - creation."* **Added by the Ρ-F2 ruling.** That rule now applies only to a GUID carrying - no membership record: an explicit tag wins over the detector. Amend the sentence and - state the reason in one clause — the detector classifies content the *user* made, not - content the tool made and already classified. `src/shell/panel/CLAUDE.md` describes - `panel_input` only as "the new-content auto-tag timer" and does not restate the rule, so - it needs no amendment. - -**Acceptance criteria.** -- `siblingPlacement` is unit-tested over all five cases in the product doc's table (normal - mid-folder, last-in-folder `-1`, last-in-two-folders `-2`, folder parent, last track in - the project) plus a malformed non-zero-sum delta list, with no DAW. -- `captureTrackName` is unit-tested for plain, already-prefixed (idempotence), empty, and - `Track N` sources. -- `deriveRenderPaths` round-trips the same stem spelling `deriveBankPaths` produces for the - same inputs, and the existing `capture_paths` tests pass unchanged. -- Every existing capture test passes with **no expectation change** — the proof that the - destination seam is inert on the `Bank` path. -- `render_in_place.cpp` contains no reference to `session.bank()`, `session.book()`, - `recordCreated`, or `bumpBankGeneration` — checkable by grep, and the review gate for - boundary condition 1. -- The action registers, dispatches, and mirror-unregisters through the single - `ActionTableRow` — no separate registration mechanism. -- `render_in_place.cpp` contains no reference to `activeModeId` in the tagging path — - checkable by grep, and the review gate for the Ρ-F2 ruling. The result track and its - item are tagged `kArrangeModeId`; `activeModeId()` appears only in the `applyMode` - reapply. -- `detectNewContent` no longer auto-tags a GUID that already carries a membership record, - and the existing `view_mode_model` / `guid_diff` tests pass unchanged (the filter is - shell-side; `autoTagNewContent`'s pure contract does not move). -- A membership index carrying an explicit `kArrangeModeId` record JSON-round-trips - unchanged, and `isMember` answers identically for that record and for an absent one — - one added `view_mode_model` test, no DAW. -- All four invariant amendments are in the diff. - -**DAW-verification obligation** (stated up front; nothing past the pure functions is -unit-testable): -- **The null test on Ρ's own output** — render a track over a range, polarity-invert the - source against the new track, confirm silence. The phase's trust anchor. -- **The three folder cases**, each confirming the new track's nesting level and that the - render feeds (or correctly bypasses) the folder bus. `[verify — DAW]` whether - `InsertTrackInProject` plus the two `I_FOLDERDEPTH` writes settle without an intermediate - `TrackList_AdjustWindows(false)` (SDK header 7735; header 2721 notes some attribute writes - need a manual panel update, and the `isMinor` semantics are undocumented). -- **The collapsed-mono placement** — render a dead-centre source, confirm a mono item, and - confirm it sums at the same level the stereo source did. This is root `CLAUDE.md`'s - existing `[verify — DAW]` on mono-item-on-stereo-track summing, **promoted to - load-bearing by this phase**: Ρ is the first path where a collapsed render is placed into - the mix by the tool itself. -- **Both mode transitions under the Ρ-F2 ruling** — fired from Arrange (source parks; - result track visible and in the mix) and fired from Design (source stays on the bench; - result track parked, then present in the source's place after a switch to Arrange). - **In each case wait out at least one panel timer tick and re-check the membership** — - that is the auto-tag-detector regression, and it is what catches a missing - explicit-tag-wins filter or an untagged item, either of which silently reverses the - ruling. The old ordering check (that the new track is never momentarily parked) no - longer applies. -- **Undo** — one Ctrl-Z removes track and item and reverts the folder-depth write; the file - survives (REAPER's undo deletes no files, and prune cannot reach this one); the source - stays tagged Design, whose way out is the existing *tag selected tracks → Arrange* action. - All three residuals are inherited from the documented model-vs-undo split - (`src/shell/view/CLAUDE.md` §Gotchas), not introduced here. -- **Name and colour clone**, including a second run over an already-prefixed track (must not - stack) and an unnamed source (must read `Capture Track N`). -- **`GetProjectPathEx` against a project with a non-default recording path**, confirming the - render lands where the project's media lives. - -**Open questions.** **No [Daniel] questions — Ρ-F1, Ρ-F2 and Ρ-F3 are all RULED** -(2026-08-02), so nothing here is gated. Two **[propose at review]** items, both -recommendation-carrying. First, whether the Design-fired path emits a one-line -`ShowConsoleMsg` (see the Feedback bullet) — recommendation: yes. Second, **[propose at -review]** whether `render_in_place` should refuse when the source track is the master — -`ResolveScopeSource` collects via `CountSelectedTracks`/`GetSelectedTrack`, which skip the -master (SDK header), so a master-only selection already resolves to zero tracks and refuses -with "nothing selected"; the recommendation is to leave that inherited behaviour alone -rather than add a message for a case the existing path already handles correctly. - ---- - ## Phase Λ — ReaSampler on Linux: both artifacts, shipped **Ships:** `reaper_reasampler.so` and `reasampler_9000.vst3` built, installed and documented @@ -4219,7 +3854,8 @@ proof it exists to give. day it was framed, so the track here is not gated on a decision; see "Decision state" above. **Ρ-F2 was the one that moved the spec** — the result track goes to Arrange unconditionally rather than following the active mode, which is also the only reason the - phase touches `panel_input.cpp` at all. + phase touches `panel_input.cpp` at all. **Ρ-W1-T1 has landed**; see `docs/COMPLETED.md` + for the full narrative. - **All of Phase Λ** (`pl-*`). **Thirteen pending tracks across seven waves** (Λ-W2…Λ-W8), plus Λ-W1's two audit tracks, which are landed. From a direct request (Daniel, 2026-08-02), not from `TODO-1.0.md`; the product reasoning lives in `docs/product/linux-readiness.md` @@ -4373,6 +4009,7 @@ Phase Epsilon — The bank package (none of the seventeen; a direct reque textually adjacent only — serialize T2 behind T1 if zero contention is wanted. Phase Rho — Render in place (none of the seventeen; a direct request 2026-08-02) + [LANDED — Ρ-W1-T1; see docs/COMPLETED.md; full detail section removed from this file] W1 The verb [ONE track, deliberately] T1 render-in-place ............. render selected track -> new sibling track, item placed at the exact render position, From 5f971e60cddaddf639a2e7498c21ba3aee7580b3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 15:56:57 -0400 Subject: [PATCH 07/48] Close three critical review findings on the render-bounds-channel verdict Verdict can no longer print a false EXACT on an on-grid end, no longer names a bounds channel a content-derived render never consulted, and the grid-align doc premise is corrected without implementing it. --- docs/TODO.md | 89 ++++++-- docs/VERIFICATION.md | 9 +- src/core/capture/CLAUDE.md | 10 +- src/core/capture/render_settings.cpp | 39 +++- src/core/capture/render_settings.h | 60 +++++- src/core/capture/render_window.cpp | 73 ++++++- src/core/capture/render_window.h | 63 +++++- src/shell/capture/CLAUDE.md | 9 +- src/shell/capture/capture.cpp | 121 +++++++++-- src/shell/capture/capture_orchestrator.cpp | 3 +- src/shell/capture/render_bounds_gate.cpp | 19 +- src/shell/capture/render_bounds_gate.h | 7 + tests/test_render_settings.cpp | 105 +++++++++- tests/test_render_window.cpp | 224 +++++++++++++++++++++ 14 files changed, 750 insertions(+), 81 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 27a93a6..515703a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -710,37 +710,86 @@ refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own statu earlier note here preferred a distinct status, and that preference is unresolved, not withdrawn. -## An offline capture can be refused for a short render — root cause open +## An offline capture can be refused for a short render — the millisecond floor -**Symptom (live, 2026-08-02).** A capture over [0.000000s, 4.067797s) at 48 kHz was -refused: `Render produced 195216 frames but the requested range is 195254`. 38 frames -short — 38x the gate's one-frame tolerance, so the tolerance is not what refused it. +**Measured cause (live, 2026-08-02).** Two captures at 48 kHz, `TailMode::None`, matched +their landed frame count to the frame with their window's END floored to the millisecond: +`[0s, 1.6551724137931001s)` printed 79440 of 79448 (the console's own `%.17g` read-back), +and `[0s, ~4.067797s)` — the double nearest the six-decimal value the original refusal +actually printed, `4.0677966101694913`, not itself a captured console value — printed +195216 of 195254. A three-checkpoint read-back on `RENDER_STARTPOS`/`RENDER_ENDPOS` was +silent at store time and immediately before the render, so REAPER writes the floored end +back as a side effect of rendering, not before it. Mechanism, why two +`RENDER_BOUNDSFLAG` channels exist, and the live experiment this observation opened: +`src/core/capture/render_settings.h`'s `RenderBoundsChannel` — the one narrative home; +this entry stays the record of what was actually measured. -**Hypothesis A — the render bounds itself to the media it can see.** REAPER's -selected-items render source (`&32`) derives its bounds from the selected items' own -extents (`src/core/capture/CLAUDE.md` §Gotchas — itself an inference from an observed -defect, not a header fact). If a time-bounded selected-tracks render (`&128`) does the -same thing against content extent, a range running past the end of its material comes up -exactly as short as the material is. +**Disproven by that observation.** The two hypotheses this entry previously carried — that +the render bounds itself to the media it can see, and that a trailing-silence trim fires +despite `RENDER_NORMALIZE = &(4<<16)` — both predict a shortfall tracking CONTENT. This +one tracks the WINDOW: it is the exact ms-floored count, whatever the material does. +Neither is the cause. Do not reinstate either without a fresh observation. -**Hypothesis B — a trailing-silence trim fires anyway.** `TailMode::None` sets -`RENDER_NORMALIZE = &(4<<16)` (disable all postprocessing) and `RENDER_TRIMEND = 0`. If -REAPER trims regardless of that bit, a range whose material decays before its end loses -exactly the decayed frames. +**Still open — the START edge.** Every observation to date started at `0s`. Floor, ceil +and round all leave `0s` alone (it is exactly representable in binary), so nothing is +known about whether the start floors too, and it is the case that matters most: an end +floor refuses loudly, a start floor would shift content and break the null test silently. + +**A premise NOT to build on — an on-grid value is not uniformly safe from a bare floor.** +That claim holds for `0s` only because `0` is exact in binary; it is FALSE in general for +a decimal millisecond grid point: `1.007 * 1000 == 1006.9999999999999` (floors to 1006, +not 1007), and `4.068 * 1000 == 4067.9999999999995` (floors to 4067, not 4068). If a +future fix compensates for a discovered START floor by grid-aligning the extraction — e.g. +slicing a buffer from `floor(start_ms)` — and builds that arithmetic on the false premise, +a `1.007`-class start would resolve a whole millisecond early: frame count right, content +shifted, exactly the silent null-test misalignment this effort exists to catch. Neither +live observation above can detect this hazard (both are off-grid sub-millisecond +remainders, not grid points) — a `1.007`-class grid point must be measured in the DAW +before any extraction logic is built on this premise. `render_window`'s own +`isOnMillisecondGrid`/`floorToMilliseconds` already handle this correctly, but only on OUR +side of the boundary; that tolerance cannot influence how REAPER itself resolves a value we +hand it, which is the open question here, not a closed one. + +**Still open — where the floor lives.** Custom time bounds is one of eight +`RENDER_BOUNDSFLAG` modes. Whether the floor sits in that field's own render-time +resolution or downstream in the render engine (where no mode escapes it) cannot be +answered from the SDK header. `RenderBoundsChannel` +(`src/core/capture/render_settings.h`) is the experiment; `docs/VERIFICATION.md` +§Capture range and bounds is the one smoke run that settles it. **Not excluded — the gate itself.** `renderHonoredBounds`' one-frame tolerance is empirical, not proven (`src/core/capture/render_window.h`): a renderer that resolves the window's two edges by DIFFERENT conventions can sit two frames from `frameCountFor`'s answer on a correctly-honored render. That cannot account for 38 frames, so it is not -this refusal — but it means a future one- or two-frame refusal may be ours, which is why +these refusals — but it means a future one- or two-frame refusal may be ours, which is why the tolerance was not widened on speculation. Widening it is a precision-invariant decision, not a bug fix. -**How it gets decided.** `docs/VERIFICATION.md` §Capture range and bounds, the three -numbered blocker steps: step 1 separates A's `&32` path from the shared `&128` path (and -says how to tell when it failed to), step 2 asks whether the render is short at all, step -3 reads the retained refused render to place the missing frames. Nothing here should be -"fixed" before that comes back. +## `capture.cpp` is over the ~600-line ceiling — documented, not split mid-experiment + +**Context.** The bounds-channel live experiment (`RenderBoundsChannel`, this same +section above) added the time-selection guard/read-back plumbing and the always-on +verdict print to `OfflineRenderBackend::capture`, landing the file at 697 lines against +root `CLAUDE.md`'s ~600-line ceiling. The named seam: the drift/verdict instrumentation +block (`ScopedTimeSelection`/read-back/drift-report/verdict-print, roughly +`capture.cpp:449-655`). + +**Deferred, not silent.** ≈60 of the added lines are temporary probe instrumentation +with a known removal date (the experiment closes when `docs/VERIFICATION.md` §Capture +range and bounds comes back), and splitting the file mid-experiment risks moving the +exact code the smoke run is measuring. Split after the experiment closes, onto the seam +named above. + +## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated) + +Idea considered and dropped: read a captured file's `BWF:TimeReference` tag back as +independent evidence on the START-edge millisecond-floor question above. `WDL/metadata.h`'s +`WriteMetadataPrefPos` only writes it past its `prefpos > 0.0` guard (`:1301`) — that guard +alone is enough to rule the approach out. One nuance worth recording separately: the +millisecond quantization at `:1382-1383` (`AddMexMetadata`'s `ParseUInt64(val)/1000.0`) +belongs to the MEX caller, not proven to be `WriteMetadataPrefPos`'s own behavior or the +renderer's direct call into it — so even without the guard, a floored bext tag would show +that MEX quantizes, not that the render engine does. ## Split `render_bounds_gate` on the verdict/message vs. filesystem seam diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index c8e8bc0..9165e80 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -26,10 +26,11 @@ Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, i - [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`PLAN.md:2136`) - [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`PLAN.md:2137`) - [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md:829`) -- [ ] **Open blocker — root cause unknown; the three steps below are the experiment** (both live hypotheses and what is NOT yet excluded: `docs/TODO.md` §An offline capture can be refused for a short render). A live capture over [0.000000s, 4.067797s) was refused 38 frames short (195216 of 195254 at 48 kHz). Set View → time unit to Samples first -- [ ] **Step 1 — does the shortfall follow the render source?** This only tests anything if item scope actually reaches REAPER's selected-items render, and it does that ONLY when the selected items' extent already equals the requested window (`itemExtentPrintsWindow`, `src/core/capture/render_window.h`); otherwise item scope re-sources through the items' own tracks — the same source track scope uses, so the two runs would test one thing twice. So: snap the time selection to the item's exact start and end, run **item** scope, then **track** scope over the identical range. Report both `Render source:` lines and both frame counts. **If both lines read `selected tracks via master`, the item path was NOT exercised** — the extents did not match; re-snap and repeat before concluding anything. **A landing `&32` run here is not evidence `&32` honours custom bounds** — at a window snapped to the item's own extent, a render that honours the window and one that bounds itself to media content print IDENTICAL frames, so this step cannot tell those two apart; it only tells you which render source is in play. **If neither run refuses at this snapped range, the blocker did not reproduce here** — this range does not recreate the original refusal, which ran past the end of its media; move to step 2, which does -- [ ] **Step 2 — full-length or short?** Extend the same range ~1 s past the end of all media, **track** scope. Landing with the full range (no refusal, the card reads the extended length) rules out BOTH a trailing-silence trim and a content-extent bound at once — but it also means there is no refused render for step 3 to read; re-run the ORIGINAL refusing range ([0.000000s, 4.067797s), track scope) to produce one before continuing. A short render does NOT tell the two hypotheses apart: a trim firing despite `RENDER_NORMALIZE &(4<<16)` and a render bounding itself to content extent produce the same count — and that render IS the one step 3 reads. Report which happened, then run step 3 -- [ ] **Step 3 — where are the missing frames?** Reads the short render from step 2 (or, if step 2 landed, the fresh refused render from re-running the original range per step 2's note) — not anything step 1 may have left behind, since a correctly-snapped step 1 should not have refused at all. A refused render is kept deliberately, not deleted: it is moved to `/reasampler_refused/`. **Follow the path in the refusal line, not this sentence** — if the move itself failed the file stays in the bank folder, unindexed, and the line says which happened. Filenames carry a timestamp/counter but no scope marker, so if more than one file has landed in `reasampler_refused/` by now, the one from step 2 is the most recently written one — or empty the folder before running step 2 so there is only one candidate. Insert it against the source over the same range and report whether the head aligns. Frames missing from the TAIL with an aligned head fits either a tail trim or a content-extent bound; a head offset fits neither and is a start-position defect. Also report whether the media under the range ends before the range does. Delete `reasampler_refused/` when done — nothing in the bank references it +- [ ] **The millisecond floor — what to expect.** A custom-bounds render is known to floor its window's END to the millisecond and write the floored value back over `RENDER_ENDPOS`. Mechanism, why two `RENDER_BOUNDSFLAG` channels exist, and the two live observations behind this: `src/core/capture/render_settings.h`'s `RenderBoundsChannel` and `docs/TODO.md` "An offline capture can be refused...". **Both live observations started at `0s`, on the grid, so nothing is known about the START edge** +- [ ] **The one experiment — does another bounds mode escape the floor?** This build renders on the TIME SELECTION channel (`RENDER_BOUNDSFLAG=2`, window handed over via `GetSet_LoopTimeRange`) instead of custom time bounds. Every capture prints one line beginning `ReaSampler capture -- bounds channel:` — 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: a bare **SHORT**/**LONG** (no tag), or one naming "floored to the millisecond", is the floor's signature — it did not escape this channel. **(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. **EXACT on a window whose END is off the millisecond grid** is the fix — the floor did not reach this channel. **EXACT on a window whose END lands on the grid is NOT conclusive**: the line adds "The END edge is UNTESTED here too" — a floored render prints the identical count by coincidence, so re-run with an off-grid end 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 render source itself derives its own bounds and never consulted the channel — selected-items/razor captures always read this way, so pick a window narrower than the selected item(s) to route through the time-bounded source instead +- [ ] **Same run, the START edge.** The verdict line also says whether the run tested the start. Capture a range whose start is NOT a whole millisecond (set View → time unit to Samples, then nudge the selection start off the grid) so the line reads `The START edge IS tested here`. Report that line verbatim — it is the only evidence available for whether the start floors too, and a start floor is the case that would break the null test silently rather than loudly +- [ ] **Auto and Manual tail.** Repeat the off-grid-start/off-grid-end capture once with the panel tail toggle at **Auto** and once at **Manual**. Neither is judged against a frame count, so the evidence is the `after render` drift lines: report whether either channel's bounds read back changed. `[verify — DAW]` A tail is assumed to render PAST the window end — the SDK header (`:3048`) confirms only that `RENDER_TAILMS` is a length in ms, not that it extends past the end. If that assumption is wrong, an end floor could be costing Auto/Manual real content with no detector (`checkRenderedBounds` returns immediately for `tailMode != None`) — so also report by ear/measurement whether either tail capture comes up short against the source, not only whether the bounds fields drifted +- [ ] Whichever way the experiment lands, the refused render is still kept for diagnosis at `/reasampler_refused/` (the refusal line names the path; a failed move leaves it unindexed in the bank folder and says so). Delete the folder when done — nothing in the bank references it ## Names and channels diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 32c6ca3..88abdc6 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -51,8 +51,8 @@ Detail specific to these pure modules: - `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives. - `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates. - `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`. -- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal). -- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the two short-render diagnostics: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched, quoted by the refusal as a count coincidence and nothing more) and `describeBoundsDrift` (the sentence the offline backend prints when `RENDER_STARTPOS`/`RENDER_ENDPOS` do not read back as they were written). +- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the bounds channel a capture hands its window over on (`RenderBoundsChannel`/`renderBoundsFlagFor`/`renderBoundsChannelLabel`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal). +- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the short-render diagnostics: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched, quoted by the refusal as a count coincidence and nothing more), `describeBoundsDrift` (the sentence the offline backend prints when a channel's stored bounds do not read back as they were written), `isOnMillisecondGrid` (whether an observed edge can speak to a rounding question at all — an on-grid edge cannot), and `describeBoundsExperiment` (the always-printed verdict naming which bounds channel carried a capture's window and what the landed file measured). - `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them. - `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project. @@ -79,6 +79,12 @@ Detail specific to these pure modules: that with a transient silencing (`shell/capture/render_isolation`) whose child-set walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in `src/shell/capture/CLAUDE.md`. +- **The render window floors to the millisecond at render time.** Measured cause, + why two bounds channels exist, and the live experiment: `render_settings.h`'s + `RenderBoundsChannel` — the one narrative home; this bullet is a pointer, not a + retelling. The one fact worth keeping local: every observation to date started at + `0s`, on the grid, so **nothing is known about whether the start floors too** — + assume neither. - `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline render has no true pre-FX "dry" bit; FX scoping is done entirely by the FX-bypass-around-render mechanism, never by a render bit. diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index 5cec2f2..83ca559 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -14,7 +14,36 @@ double autoTrimEndRatio() { return std::pow(10.0, kAutoTrimThresholdDb / 20.0); } -TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { +int renderBoundsFlagFor(RenderBoundsChannel channel) { + switch (channel) { + case RenderBoundsChannel::CustomTimeBounds: return 0; + case RenderBoundsChannel::TimeSelection: return 2; + } + // Unreachable for a valid enum; fail closed to the channel every shipped capture + // rendered on, never to a mode that bounds itself off something else entirely. + return 0; +} + +int tailFlagBitFor(RenderBoundsChannel channel) { + switch (channel) { + case RenderBoundsChannel::CustomTimeBounds: return kTailFlagCustomBounds; + case RenderBoundsChannel::TimeSelection: return kTailFlagTimeSelection; + } + return kTailFlagCustomBounds; // paired with renderBoundsFlagFor's fallback +} + +const char* renderBoundsChannelLabel(RenderBoundsChannel channel) { + switch (channel) { + case RenderBoundsChannel::CustomTimeBounds: + return "custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS)"; + case RenderBoundsChannel::TimeSelection: + return "time selection (RENDER_BOUNDSFLAG=2, GetSet_LoopTimeRange)"; + } + return "unnamed bounds channel"; +} + +TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs, + RenderBoundsChannel channel) { TailRenderSettings t; switch (mode) { case TailMode::None: @@ -30,7 +59,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { // postprocessing bit clear. A fixed-threshold trim scales/limits/fades // nothing, so identical requests trim at the identical sample -> holds // the bit-identical-repeats invariant. - t.tailFlag = kTailFlagCustomBounds; + t.tailFlag = tailFlagBitFor(channel); t.tailMs = kMaxTailMs; t.normalize = kNormalizeTrimEnd; t.trimEnd = autoTrimEndRatio(); @@ -38,7 +67,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { case TailMode::Manual: // Clamped to the cap regardless of source; negative floors to 0. - t.tailFlag = kTailFlagCustomBounds; + t.tailFlag = tailFlagBitFor(channel); t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.normalize = kNormalizeDisableAll; t.trimEnd = 0.0; @@ -114,6 +143,10 @@ const char* renderSourceLabel(SourceMode mode) { return "unknown"; // unreachable for a valid enum; never claim a source } +bool sourceBypassesBoundsChannel(SourceMode mode) { + return mode == SourceMode::SelectedItems || mode == SourceMode::RazorArea; +} + SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) { switch (scope) { case CaptureScope::Item: diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 6115a63..34a627c 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -27,17 +27,51 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e // render wet; the scope decides which FX remain enabled. inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file +// --- Render bounds channel ---------------------------------------------------- +// +// The RENDER_BOUNDSFLAG mode a capture hands its window over on (values verbatim, +// header ~3042: 0 = custom time bounds, 2 = time selection). RENDER_STARTPOS / +// RENDER_ENDPOS apply to mode 0 ONLY (header ~3045-3046), so the TimeSelection +// channel carries the window in the project's own time selection instead — a +// different store. That difference is the whole reason two channels exist: REAPER +// resolved a custom-bounds window on a whole-millisecond grid, floored the end, wrote +// the floored value back over RENDER_ENDPOS, and rendered exactly the floored frame +// count — twice, to the frame (docs/TODO.md "An offline capture can be refused..." +// records the observations). The same read-back at store time and immediately before +// the render was silent, so the field itself holds full double precision and the floor +// happens at render time. Whether that floor sits in the custom-bounds channel or +// downstream in the render engine (where no bounds mode escapes it) cannot be settled +// from the SDK header, only in a DAW. The offline backend therefore names the channel +// it used and what the landed file measured (render_window::describeBoundsExperiment) +// so one smoke run answers it. This is the one narrative home for why two channels +// exist; other sites point here rather than retelling it. +enum class RenderBoundsChannel { + CustomTimeBounds, + TimeSelection, +}; + +// The RENDER_BOUNDSFLAG value for a channel. +int renderBoundsFlagFor(RenderBoundsChannel channel); + +// The channel in words, for the console verdict. +const char* renderBoundsChannelLabel(RenderBoundsChannel channel); + // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // -// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG, -// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE +// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047), so the bit a +// tail mode has to set follows the bounds channel the window went over — a tail +// set under the other channel's bit renders no tail at all. RENDER_NORMALIZE // (verbatim, header ~3051): &32768 = trim ending silence (Auto path); // &(4<<16) = disable all render postprocessing (None/Manual path). inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all -inline constexpr int kTailFlagNone = 0; -inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047 +inline constexpr int kTailFlagNone = 0; +inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047 +inline constexpr int kTailFlagTimeSelection = 4; // &4, header ~3047 + +// The RENDER_TAILFLAG bit that applies to a channel's bounds mode. +int tailFlagBitFor(RenderBoundsChannel channel); // Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND // ratio derives from this dB, never the reverse). Daniel-set. @@ -65,15 +99,18 @@ enum class TailMode { // normalize bit is set (Auto). The backend reads these straight onto // GetSetProjectInfo. struct TailRenderSettings { - int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1) + int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the channel's bit) double tailMs = 0.0; // RENDER_TAILMS int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set) }; // Maps a tail mode (+ requested manual tail ms, used only for Manual) to its -// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. -TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); +// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. `channel` +// is a parameter rather than a caller-side OR so a bounds-channel change cannot +// leave Auto/Manual setting a tail bit the render no longer reads. +TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs, + RenderBoundsChannel channel); // The realtime record-window end (project seconds): realtime does NOT drive // RENDER_*, it records a generous window and trims later, so this is where the @@ -108,6 +145,15 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); // naming them apart would assert a render distinction that does not exist. const char* renderSourceLabel(SourceMode mode); +// True for a render source that derives its bounds from content rather than from +// RENDER_BOUNDSFLAG at all: SelectedItems (&32) and RazorArea (&4096) bound +// themselves to the selected items'/areas' own extents (see the &32 inference in +// src/core/capture/CLAUDE.md §Gotchas; RazorArea is read the same way, sharing the +// single-file bit for the same reason). A capture on one of these never consults +// RenderBoundsChannel, so describeBoundsExperiment's verdict must not be read as +// evidence about the channel for it — the caller names the source instead. +bool sourceBypassesBoundsChannel(SourceMode mode); + // --- Capture scope: the FX-scope invariant ------------------------------------ // // See src/core/capture/CLAUDE.md for the scope contract. There is NO master diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index 40e2840..225e18e 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -18,9 +18,8 @@ long long frameIndexAt(double seconds, int sampleRate) { // See the header for why whole milliseconds get a tolerance and why it is this small. double floorToMilliseconds(double seconds) { - const double ms = seconds * 1000.0; - const double nearest = std::nearbyint(ms); - if (std::fabs(ms - nearest) < 1e-6) return nearest / 1000.0; + const double ms = seconds * 1000.0; + if (isOnMillisecondGrid(seconds)) return std::nearbyint(ms) / 1000.0; return std::floor(ms) / 1000.0; } @@ -58,11 +57,79 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, && frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate); } +bool isOnMillisecondGrid(double seconds) { + const double ms = seconds * 1000.0; + return std::fabs(ms - std::nearbyint(ms)) < 1e-6; +} + long long msFlooredEndFrameCount(double startSeconds, double endSeconds, int sampleRate) { return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate); } +std::string describeBoundsExperiment(const char* channelLabel, + double reqStart, double reqEnd, + long long actualFrames, int sampleRate, + const char* bypassingSourceLabel) { + std::string s = "bounds channel: "; + s += (channelLabel && channelLabel[0]) ? channelLabel : "unnamed bounds channel"; + s += ". "; + + if (bypassingSourceLabel && bypassingSourceLabel[0]) { + s += "NOT JUDGED -- rendered from " + std::string(bypassingSourceLabel) + + ", which derives its bounds from content and never consulted this channel;" + " this capture is not evidence either way about it."; + return s; + } + + if (sampleRate <= 0) { + s += "NOT JUDGED -- the landed render'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; + // 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. + 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 -- this channel did not escape the floor."; + } + + 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."; + + // An EXACT verdict on an on-grid END is not proof: a channel that floors the end + // would have printed this same count, since floor/ceil/round all leave a grid point + // alone. Without this, EXACT reads as settled when this run could not have told the + // two apart. + if (delta == 0 && isOnMillisecondGrid(reqEnd)) { + s += " The END edge is UNTESTED here too: " + exactly(reqEnd) + + "s is already on the millisecond grid, so a channel that floors the end" + " would have printed this same EXACT count -- re-run over a window whose" + " end is off the grid before reading EXACT as the fix."; + } + return s; +} + std::string describeBoundsDrift(double reqStart, double reqEnd, double storedStart, double storedEnd, int sampleRate) { diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index 3f96e6f..eb6df14 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -1,10 +1,10 @@ #pragma once // render_window — pure frame arithmetic for a capture's requested window: the // frame count a project-time range occupies, whether a render whose bounds come -// from the selected items' own extent already prints that window, and the two -// diagnostics that bound a short render without locating it: whether the stored -// RENDER_* bounds round-tripped, and whether the shortfall matches a millisecond- -// floor coincidence. +// from the selected items' own extent already prints that window, and the +// diagnostics that bound a short render: whether the stored bounds round-tripped, +// whether the shortfall matches a millisecond-floor coincidence, and the verdict on +// which bounds channel a capture used and what it produced. // NO REAPER types; unit-tested by tests/test_render_window.cpp. #include @@ -50,18 +50,65 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd, // --- Diagnostics: where a short render lost its frames ------------------------ // The frames this window would hold if its END were resolved on a whole-millisecond -// grid, floored, instead of exactly. Two live short renders (48 kHz, TailMode::None) -// matched this count to the frame, which is the entire reason it exists. +// grid, floored, instead of exactly. That is what REAPER's offline render does: two +// live short renders (48 kHz, TailMode::None) printed this count to the frame, and the +// after-render read-back showed REAPER's own resolved end floored to the same value. // -// A COINCIDENCE OF COUNTS, not a claim about how anything resolved the end: nothing -// renders from this number and no capture path asks for it. Whole-millisecond values +// Still a DESCRIPTION, never a request: nothing renders from this number and no capture +// path asks for it — a refusal quotes it to say the shortfall has the known shape, which +// is not the same as proving that this particular render took it. Whole-millisecond values // are recognized within a nanosecond, because a decimal millisecond is not always one // in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a // millisecond from a window already on the grid. A nanosecond is far under one frame // at any rate we render, so a real sub-millisecond remainder still floors. +// +// The tolerance is ours, not REAPER's: on a `1.007`-class grid point, a REAPER floor +// that does NOT carry the same epsilon would miss this shape entirely, and a real +// floored render would then read as an unmatched SHORT rather than the known one — +// silence here is not proof the floor didn't happen (docs/TODO.md records why this +// premise needs a DAW measurement before anything is built on it). long long msFlooredEndFrameCount(double startSeconds, double endSeconds, int sampleRate); +// True when `seconds` sits on a whole-millisecond boundary, under the same nanosecond +// tolerance msFlooredEndFrameCount uses and for the same reason (stated there). +// +// Load-bearing for reading a bounds observation: an on-grid edge is left alone by +// floor, ceil and round alike, so a window whose START is on the grid can say nothing +// about whether REAPER resolves the start edge the way it resolves the end. +bool isOnMillisecondGrid(double seconds); + +// The one-line verdict on what a capture's bounds channel did with its window: which +// channel carried it (or, when the render source defines the window itself, which +// source bypassed the channel entirely), the frames the landed file holds against the +// frames the window asks for, and whether this run could test the START and END edges +// at all. Always non-empty — a capture that answered nothing has to say so, or its +// silence reads as a pass. +// +// EXACT never stands alone as proof: an END that sits on the millisecond grid prints +// the SAME EXACT count whether the channel honored the window or floored it and landed +// back on the grid by coincidence, so that case is called out in the sentence rather +// than left to read as settled — same principle as the existing START-edge caveat. +// +// 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; a bare SHORT/LONG, or a delta matching +// msFlooredEndFrameCount exactly, is the floor's signature. +// +// `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. +// +// `bypassingSourceLabel`, when non-null and non-empty, means the render source itself +// defined the window (render_settings::sourceBypassesBoundsChannel) — `channelLabel` was +// never consulted, so the verdict names the source instead and reads NOT JUDGED +// regardless of how the frame counts compare. +std::string describeBoundsExperiment(const char* channelLabel, + double reqStart, double reqEnd, + long long actualFrames, int sampleRate, + const char* bypassingSourceLabel = nullptr); + // The sentence a capture prints when the render bounds it handed REAPER did not read // back unchanged — the requested window, what came back, and both frame counts at // `sampleRate` (omitted when the rate is unknown). EMPTY when both edges read back diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index ea057b2..f4c980d 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -36,7 +36,14 @@ detail not covered there: - **`renderOffline` is the one seam both a fresh capture and a recipe replay cross**, which is why the refusal and both transient guards live there rather than in the action bodies — anything placed in `ResolveScopeSource` alone would - miss `RunRecaptureFromSource` entirely. + miss `RunRecaptureFromSource` entirely. The bounds channel is inside the backend + that seam calls, for the same reason: a replay must hand its window over exactly + the way a fresh capture does. +- **The bounds channel is under live experiment**, and `capture.cpp`'s + `kBoundsChannel` is its single switch. On the time-selection channel the render + window travels in the project's own time selection, so `capture` snapshots and + restores that selection like any other state it borrows. Why there are two + channels: `src/core/capture/render_settings.h`'s `RenderBoundsChannel`. - **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard neutralizes the in-scope chain — provenance must see the chain as it really is, diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 9c71758..0e683be 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -6,8 +6,9 @@ // the one TU that defines the API pointers; here they are extern. // // Drives the RENDER_* project settings via GetSetProjectInfo/_String (source- -// selection bits come from the pure render_settings mapping), snapshots and -// restores every setting it changes, triggers a render, then populates a Sample. +// selection bits come from the pure render_settings mapping) plus, on the +// time-selection bounds channel, the project time selection; snapshots and restores +// every one of them, triggers a render, then populates a Sample. // Source-agnostic: never reads the DAW selection itself, only the CaptureRequest // the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never // inserts into the arrange. @@ -61,9 +62,13 @@ namespace { // project — why we set them all explicitly first. constexpr int kActionRenderUsingMostRecentSettings = 42230; -// RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves -// for exact, unrounded bounds). SDK header ~3042. -constexpr double kBoundsCustom = 0.0; +// The bounds channel this build hands the render window over on. Why there are two, +// and the open DAW question this selection exists to answer, are stated once on +// RenderBoundsChannel (core/capture/render_settings.h) — flipping this constant back +// to CustomTimeBounds is the whole revert. +constexpr RenderBoundsChannel kBoundsChannel = RenderBoundsChannel::TimeSelection; +constexpr bool kUsesTimeSelectionBounds = + (kBoundsChannel == RenderBoundsChannel::TimeSelection); // RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure // tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below. @@ -176,6 +181,30 @@ void restoreRenderSettings(const RenderSettingsSnapshot& s) { GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true); } +// The project time selection, snapshotted and restored around a render that uses it +// as its bounds channel. Separate from ScopedRenderSettings because it is project +// state rather than a RENDER_* setting, and only one channel touches it. +// GetSet_LoopTimeRange has no project parameter (SDK header ~2670) — it acts on the +// active project, which is the one capture() already resolved and renders into. +struct ScopedTimeSelection { + bool engaged; + double start = 0.0; + double end = 0.0; + + explicit ScopedTimeSelection(bool engage) : engaged(engage) { + if (engaged) GetSet_LoopTimeRange(false, false, &start, &end, false); + } + ~ScopedTimeSelection() { + if (!engaged) return; + // Copies: the setter takes non-const pointers, so the snapshot must not be + // what it writes through. + double s = start, e = end; + GetSet_LoopTimeRange(true, false, &s, &e, false); + } + ScopedTimeSelection(const ScopedTimeSelection&) = delete; + ScopedTimeSelection& operator=(const ScopedTimeSelection&) = delete; +}; + // RAII wrapper: guarantees restore on every return path from capture(). struct ScopedRenderSettings { RenderSettingsSnapshot snap; @@ -418,18 +447,38 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { deriveBankPaths(projectDir, request.baseName, uniqueTag); ScopedRenderSettings guard(proj); + ScopedTimeSelection tsGuard(kUsesTimeSelectionBounds); - // Custom time bounds so the rendered length equals the requested range with - // NO rounding and NO added silence (unless a tail was explicitly requested). - GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true); + // The window goes over the selected channel's own store so the rendered length + // equals the requested range with NO rounding and NO added silence (unless a tail + // was explicitly requested). RENDER_STARTPOS/ENDPOS are written on BOTH channels: + // they are documented as applying to mode 0 only (SDK header ~3045-3046), so under + // the time-selection channel they are inert, and their read-back below then reports + // that field independently of the one actually carrying the window. + GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", + static_cast(renderBoundsFlagFor(kBoundsChannel)), true); GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true); + if (kUsesTimeSelectionBounds) { + double s = request.startSeconds, e = request.endSeconds; + GetSet_LoopTimeRange(true, false, &s, &e, false); + } + + // The time-selection channel's read-back, so each checkpoint below reads the store + // that actually carried the window rather than the inert RENDER_* pair. + auto readTimeSelection = [](double& s, double& e) { + s = 0.0; + e = 0.0; + GetSet_LoopTimeRange(false, false, &s, &e, false); + }; // The requested window crosses out of this process HERE and nowhere else, so the // read-back is the only evidence available on this side of that boundary for // whether REAPER kept it. Reported below, once the project rate is known. const double storedStart = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false); const double storedEnd = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false); + double storedTsStart = 0.0, storedTsEnd = 0.0; + if (kUsesTimeSelectionBounds) readTimeSelection(storedTsStart, storedTsEnd); // TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact // bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end @@ -437,7 +486,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // trim. NORMALIZE is driven here (not the determinism block below) so the // Auto surgical value isn't clobbered. const TailRenderSettings tail = - tailRenderSettingsFor(request.tailMode, request.tailMs); + tailRenderSettingsFor(request.tailMode, request.tailMs, kBoundsChannel); GetSetProjectInfo(proj, "RENDER_TAILFLAG", static_cast(tail.tailFlag), true); GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true); @@ -474,7 +523,19 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { ShowConsoleMsg(("ReaSampler capture (" + std::string(checkpoint) + "): " + drift + "\n").c_str()); }; - reportDrift("at store", storedStart, storedEnd); + + // The same checkpoint on the other channel. No-op unless that channel is the one + // carrying the window, so the console gains nothing on the custom-bounds build. + auto reportTimeSelectionDrift = [&](const char* checkpoint) { + if (!kUsesTimeSelectionBounds) return; + double s = 0.0, e = 0.0; + readTimeSelection(s, e); + reportDrift(checkpoint, s, e); + }; + + reportDrift("at store, custom-bounds fields", storedStart, storedEnd); + if (kUsesTimeSelectionBounds) + reportDrift("at store, time selection", storedTsStart, storedTsEnd); GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(request.channelCount), true); @@ -497,10 +558,36 @@ 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 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)."; @@ -510,24 +597,30 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // Read again right here: a mismatch against the store-time read-back above // means something between the two writes and this line moved the bounds, - // before the render ever ran. - reportDrift("before render", + // before the render ever ran. Both channels get the same three checkpoints, or a + // store-versus-render distinction would only be available on one of them. + reportDrift("before render, custom-bounds fields", GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false), GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false)); + reportTimeSelectionDrift("before render, time selection"); Main_OnCommand(kActionRenderUsingMostRecentSettings, 0); // And once more here, while the guard above is still live and before it restores // anything: only the gap between this read and the one immediately above can be // the render itself. - reportDrift("after render", + reportDrift("after render, custom-bounds fields", GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false), GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false)); + reportTimeSelectionDrift("after render, time selection"); // Main_OnCommand returns void, so a failed render is silent — stat the // expected output path to detect it. const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName; if (!std::filesystem::exists(expectedPath)) { + // Main_OnCommand ran but produced nothing measurable — NOT JUDGED, same as + // the format refusal above. + printBoundsVerdict(0, 0); result.status = CaptureStatus::RenderFailed; result.message = "Render produced no output file (expected: " + expectedPath + "). Check the REAPER console for errors."; @@ -541,6 +634,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { const BoundsVerdict emptyVerdict = checkRenderedFileNotEmpty(expectedPath, projectDir); if (emptyVerdict.refused) { + reportExperiment(emptyVerdict); result.status = CaptureStatus::BoundsMismatch; result.message = emptyVerdict.message; return result; @@ -554,6 +648,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // on an already-warm file, judged acceptable.) const BoundsVerdict bounds = checkRenderedBounds(expectedPath, projectDir, request); + reportExperiment(bounds); if (bounds.refused) { result.status = CaptureStatus::BoundsMismatch; result.message = bounds.message; diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 09ca4c7..edf0a5c 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -231,7 +231,8 @@ CaptureResult renderOffline(CaptureScope scope, // caller can report success/failure. Load-bearing principle holds: writes a file + // a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the // out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), -// and the backend restores every RENDER_* setting. +// and the backend restores every RENDER_* setting it changed plus, on the +// time-selection bounds channel, the project time selection it borrowed. // // On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id // on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index c0b7311..51bc72d 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -97,22 +97,23 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const long long expectedFrames = frameCountFor(request.startSeconds, request.endSeconds, rate); const long long actualFrames = static_cast(layout.frameCount()); + v.measuredFrames = actualFrames; + v.measuredRate = rate; if (renderHonoredBounds(expectedFrames, actualFrames)) return v; - // Says whether this shortfall has the one shape two live short renders already - // matched to the frame: the END alone floored to the millisecond. Checked against - // the END only -- a refusal whose START is also off-grid and independently floored - // would not match this shape, and this note's silence on that refusal is this - // check not covering it, not the coincidence breaking. Excludes 0, which every - // sub-millisecond window (a legitimate day-one capture) also floors to, and which - // would otherwise match a render that produced nothing. A count coincidence only — - // it does not establish how the render resolved anything. + // Says whether this shortfall has the known shape: the END alone floored to the + // millisecond, which is what REAPER's render was measured doing. Checked against the + // END only -- a refusal whose START is also off-grid and independently floored would + // not match this shape, and this note's silence on that refusal is this check not + // covering it. Excludes 0, which every sub-millisecond window (a legitimate day-one + // capture) also floors to, and which would otherwise match a render that produced + // nothing. const long long msFlooredEnd = msFlooredEndFrameCount(request.startSeconds, request.endSeconds, rate); const std::string msNote = (msFlooredEnd > 0 && actualFrames == msFlooredEnd) ? " Those are exactly the frames this window holds with its end floored to" - " the millisecond -- a match on the count, not a measured cause." + " the millisecond -- the shape REAPER's render was measured producing." : std::string(); v.refused = true; diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index 27a4eb1..e47722f 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -16,6 +16,13 @@ namespace reasampler::capture { struct BoundsVerdict { bool refused = false; std::string message; // console text; meaningful only when refused + + // What the landed file measured, when this verdict measured it at all. A rate of 0 + // means it did not — a tail mode is not judged here, the parse failed, or this is + // the emptiness check, which counts no frames. Carried so the backend's bounds + // verdict can report the count without opening the file again. + long long measuredFrames = 0; + int measuredRate = 0; }; // Judges `renderedPath` against `request`'s window. Refuses on two counts: the file's diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index e524eb3..f048265 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -111,18 +111,26 @@ static void testLabelsSeparateExactlyWhatTheRenderSeparates() { // --- tail: TailMode -> RENDER_* mapping (docs/product/capture-tail.md) -------- +// The tail assertions below are about the MODE's mapping; the one value that also +// depends on the bounds channel has its own test, so they all pin the channel that +// every shipped capture rendered on. +static TailRenderSettings tailFor(TailMode mode, double manualTailMs) { + return tailRenderSettingsFor(mode, manualTailMs, + RenderBoundsChannel::CustomTimeBounds); +} + static void testTailNoneIsExactBounds() { // None -> exact bounds, byte-identical to the pre-tail capture: tail flag clear, // 0 ms, disable-all normalize (the current default), no trim. Asserting the exact // bit values (not just "some value") pins the byte-identical contract: if the // mapping regressed to set a tail bit or a non-disable-all normalize, this fails. - TailRenderSettings t = tailRenderSettingsFor(TailMode::None, 0.0); + TailRenderSettings t = tailFor(TailMode::None, 0.0); CHECK(t.tailFlag == kTailFlagNone); // 0 CHECK(t.tailMs == 0.0); CHECK(t.normalize == kNormalizeDisableAll); // 262144 CHECK(t.trimEnd == 0.0); // manualTailMs must be ignored for None (a stray tail from a leftover ms is the bug). - TailRenderSettings t2 = tailRenderSettingsFor(TailMode::None, 5000.0); + TailRenderSettings t2 = tailFor(TailMode::None, 5000.0); CHECK(t2.tailFlag == kTailFlagNone); CHECK(t2.tailMs == 0.0); } @@ -131,7 +139,7 @@ static void testTailAutoIsSurgicalTrim() { // Auto -> custom-bounds tail bit, 8 s cap, SURGICAL normalize (ONLY &32768), and // the -72 dB TRIMEND ratio. The disable-all bit must NOT be set (it is semantically // opposed to trim — this assertion catches a regression to the None normalize). - TailRenderSettings t = tailRenderSettingsFor(TailMode::Auto, 0.0); + TailRenderSettings t = tailFor(TailMode::Auto, 0.0); CHECK(t.tailFlag == kTailFlagCustomBounds); // &1 CHECK(t.tailMs == kMaxTailMs); // 8000 CHECK(t.normalize == kNormalizeTrimEnd); // exactly 32768, nothing else @@ -139,7 +147,7 @@ static void testTailAutoIsSurgicalTrim() { // TRIMEND is the derived -72 dB ratio ~= 0.00025119 (the DAW-confirm value). CHECK(std::fabs(t.trimEnd - 0.00025119) < 1e-8); // manualTailMs is ignored for Auto (Auto always uses the 8 s cap). - CHECK(tailRenderSettingsFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs); + CHECK(tailFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs); } static void testAutoTrimRatioDerivesFromDb() { @@ -147,7 +155,7 @@ static void testAutoTrimRatioDerivesFromDb() { // float — recompute it independently and require an exact match with the mapping. double expected = std::pow(10.0, kAutoTrimThresholdDb / 20.0); CHECK(autoTrimEndRatio() == expected); - CHECK(tailRenderSettingsFor(TailMode::Auto, 0.0).trimEnd == expected); + CHECK(tailFor(TailMode::Auto, 0.0).trimEnd == expected); // Sanity: -72 dB is well below unity but above zero. CHECK(expected > 0.0 && expected < 0.001); } @@ -156,7 +164,7 @@ static void testTailManualFixedNoTrim() { // Manual -> custom-bounds tail, the requested ms (within cap), disable-all // normalize (no trim). A Manual capture is a fixed tail, so it keeps today's // disable-all exactly like the no-tail path. - TailRenderSettings t = tailRenderSettingsFor(TailMode::Manual, 2500.0); + TailRenderSettings t = tailFor(TailMode::Manual, 2500.0); CHECK(t.tailFlag == kTailFlagCustomBounds); CHECK(t.tailMs == 2500.0); CHECK(t.normalize == kNormalizeDisableAll); @@ -165,12 +173,67 @@ static void testTailManualFixedNoTrim() { static void testTailManualClampsToCap() { // The 8 s cap is a runaway guard that applies to Manual too: ms > 8000 -> 8000. - CHECK(tailRenderSettingsFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs); - CHECK(tailRenderSettingsFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs); + CHECK(tailFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs); + CHECK(tailFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs); // Below the cap is passed through unchanged. - CHECK(tailRenderSettingsFor(TailMode::Manual, 100.0).tailMs == 100.0); + CHECK(tailFor(TailMode::Manual, 100.0).tailMs == 100.0); // A negative request floors to 0 (no negative tail leaks into RENDER_TAILMS). - CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0); + CHECK(tailFor(TailMode::Manual, -50.0).tailMs == 0.0); +} + +// --- bounds channel: RENDER_BOUNDSFLAG mode + the tail bit it drags along ------ + +static void testEachChannelNamesItsOwnBoundsFlagMode() { + // The two RENDER_BOUNDSFLAG values, as literals from the SDK header — 0 = custom + // time bounds, 2 = time selection. Pinned as numbers so a renumbering of the enum + // cannot silently point a capture at "entire project" or "selected media items". + CHECK(renderBoundsFlagFor(RenderBoundsChannel::CustomTimeBounds) == 0); + CHECK(renderBoundsFlagFor(RenderBoundsChannel::TimeSelection) == 2); +} + +static void testTailBitFollowsTheBoundsChannel() { + // RENDER_TAILFLAG's bits are per-bounds-mode: &1 covers custom time bounds, &4 + // covers the time selection. A tail set under the other channel's bit renders no + // tail at all, which is why the mapping takes the channel rather than trusting a + // caller to OR the right one in. + CHECK(tailFlagBitFor(RenderBoundsChannel::CustomTimeBounds) == 1); + CHECK(tailFlagBitFor(RenderBoundsChannel::TimeSelection) == 4); + + // Both tail-bearing modes follow it — a fix applied to Auto alone would leave + // Manual rendering under a bit the bounds mode does not read. + for (TailMode mode : {TailMode::Auto, TailMode::Manual}) { + CHECK(tailRenderSettingsFor(mode, 2500.0, + RenderBoundsChannel::CustomTimeBounds) + .tailFlag == kTailFlagCustomBounds); + CHECK(tailRenderSettingsFor(mode, 2500.0, + RenderBoundsChannel::TimeSelection) + .tailFlag == kTailFlagTimeSelection); + } +} + +static void testNoneSetsNoTailBitOnEitherChannel() { + // None is exact bounds on every channel: no tail bit, so no channel's bit either. + CHECK(tailRenderSettingsFor(TailMode::None, 5000.0, + RenderBoundsChannel::CustomTimeBounds) + .tailFlag == kTailFlagNone); + CHECK(tailRenderSettingsFor(TailMode::None, 5000.0, + RenderBoundsChannel::TimeSelection) + .tailFlag == kTailFlagNone); +} + +static void testTheChannelLabelNamesTheModeAndItsStore() { + // The console verdict is read by someone deciding which channel to keep, so the + // label has to name both the mode number and where the window actually went. + const std::string custom = renderBoundsChannelLabel(RenderBoundsChannel::CustomTimeBounds); + CHECK(custom.find("RENDER_BOUNDSFLAG=0") != std::string::npos); + CHECK(custom.find("RENDER_STARTPOS") != std::string::npos); + + const std::string ts = renderBoundsChannelLabel(RenderBoundsChannel::TimeSelection); + CHECK(ts.find("RENDER_BOUNDSFLAG=2") != std::string::npos); + CHECK(ts.find("GetSet_LoopTimeRange") != std::string::npos); + + // Two channels that read alike in the console would make the experiment unreadable. + CHECK(custom != ts); } // --- realtimeRecordWindowEnd: the T2 record-window extension ----------------- @@ -321,6 +384,23 @@ static void testMultiTrackStemRenderIsNamedForRefusal() { CHECK(!isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, true), 2)); } +static void testSourceBypassesBoundsChannelOnlyForContentDerivedSources() { + // SelectedItems (&32) and RazorArea (&4096) derive their bounds from the + // selected items'/areas' own extents -- RENDER_BOUNDSFLAG is never consulted, so + // a bounds-channel verdict is not evidence for either (the regression this + // predicate exists to catch: RunBatchCaptureItems always renders through + // SelectedItems, so this false-EXACT would fire on every batch-item capture). + CHECK(sourceBypassesBoundsChannel(SourceMode::SelectedItems)); + CHECK(sourceBypassesBoundsChannel(SourceMode::RazorArea)); + + // Every other source is genuinely time-bounded through RENDER_STARTPOS/ENDPOS or + // the time selection, so the channel IS the evidence for these. + CHECK(!sourceBypassesBoundsChannel(SourceMode::MasterMix)); + CHECK(!sourceBypassesBoundsChannel(SourceMode::TimeSelection)); + CHECK(!sourceBypassesBoundsChannel(SourceMode::SelectedTracks)); + CHECK(!sourceBypassesBoundsChannel(SourceMode::Realtime)); +} + static void testRefusalMessagesAreSiblingsWithDistinctExits() { const std::string item = multiTrackRefusalMessage(CaptureScope::Item); const std::string track = multiTrackRefusalMessage(CaptureScope::Track); @@ -448,6 +528,10 @@ int main() { testAutoTrimRatioDerivesFromDb(); testTailManualFixedNoTrim(); testTailManualClampsToCap(); + testEachChannelNamesItsOwnBoundsFlagMode(); + testTailBitFollowsTheBoundsChannel(); + testNoneSetsNoTailBitOnEitherChannel(); + testTheChannelLabelNamesTheModeAndItsStore(); testRealtimeWindowNoneIsExact(); testRealtimeWindowAutoAddsCap(); testRealtimeWindowManualAddsClampedLength(); @@ -459,6 +543,7 @@ int main() { testScopeSourceModes(); testRangedItemScopeRendersTimeBounded(); testMultiTrackStemRenderIsNamedForRefusal(); + testSourceBypassesBoundsChannelOnlyForContentDerivedSources(); testRefusalMessagesAreSiblingsWithDistinctExits(); testRefusalMessagesMatchGoldenLiterals(); testRangeInference(); diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 9e0f0b6..721e183 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -315,6 +315,214 @@ static void testASubMillisecondStartWouldNotHideItself() { frameCountFor(1.000, end, 48000))); } +static void testTheTwoLiveShortRendersPinnedAtFullPrecision() { + // 1.6551724137931001 is the console's own %.17g read-back. 4.0677966101694913 is + // the double nearest the six-decimal value (4.067797) the earlier refusal actually + // printed -- that refusal predates the %.17g printer (git history has no commit + // introducing this literal as a console value), so it is a reconstruction, not a + // captured one. 240/145 and 240/59 (testTheSixDecimalDisplayDidNotCreateTheEffect) + // produce the SAME counts as the literals here, so this test cannot distinguish the + // real value from the reconstruction either -- it pins the count regression (full + // precision or six-decimal input, the frame counts agree), not which double REAPER + // was really handed. + CHECK(frameCountFor(0.0, 1.6551724137931001, 48000) == 79448); + CHECK(msFlooredEndFrameCount(0.0, 1.6551724137931001, 48000) == 79440); + + CHECK(frameCountFor(0.0, 4.0677966101694913, 48000) == 195254); + CHECK(msFlooredEndFrameCount(0.0, 4.0677966101694913, 48000) == 195216); + + // And the counts REAPER produced are outside the gate's tolerance in both cases — + // the refusals were correct, not an artifact of the one-frame slack. + CHECK(!renderHonoredBounds(79448, 79440)); + CHECK(!renderHonoredBounds(195254, 195216)); +} + +// --- isOnMillisecondGrid: whether an observation can speak to an edge ---------- + +static void testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap() { + CHECK(isOnMillisecondGrid(0.0)); + CHECK(isOnMillisecondGrid(2.0)); + CHECK(isOnMillisecondGrid(0.001)); + // 1.007 s does not multiply to exactly 1007.0 in double (pinned as the premise in + // testWindowAlreadyOnTheMillisecondGridLosesNothing) and must still read as on-grid. + CHECK(isOnMillisecondGrid(1.007)); + // A whole millisecond at 44.1 kHz is 44.1 frames — off the frame grid, on this one. + CHECK(isOnMillisecondGrid(0.010)); +} + +static void testOffGridRecognizesASubMillisecondRemainder() { + CHECK(!isOnMillisecondGrid(1.6551724137931001)); + CHECK(!isOnMillisecondGrid(1.0001724)); + // One frame short of a whole second at 48 kHz is ~0.0208 ms off the grid — the + // tightest remainder this predicate has to keep seeing. + CHECK(!isOnMillisecondGrid(1.0 - 1.0 / 48000.0)); +} + +// --- describeBoundsExperiment: the console verdict on a bounds channel -------- + +static void testAnExactRenderReadsExactAndNamesItsChannel() { + const std::string s = + describeBoundsExperiment("time selection (RENDER_BOUNDSFLAG=2)", + 0.0, 1.6551724137931001, 79448, 48000); + CHECK(contains(s, "EXACT")); + CHECK(!contains(s, "SHORT")); + CHECK(contains(s, "time selection (RENDER_BOUNDSFLAG=2)")); + CHECK(contains(s, "79448")); + CHECK(contains(s, "48000 Hz")); + // The END here carries a sub-millisecond remainder, so this run DID test it -- + // the END-untested caveat must not fire on a window it didn't apply to. + CHECK(!contains(s, "END edge is UNTESTED")); +} + +static void testTheLiveShortfallReadsShortAndNamesTheMillisecondShape() { + // The observation, replayed through the verdict: 79440 produced against 79448. + const std::string s = + describeBoundsExperiment("custom time bounds (RENDER_BOUNDSFLAG=0)", + 0.0, 1.6551724137931001, 79440, 48000); + CHECK(contains(s, "SHORT")); + CHECK(!contains(s, "EXACT")); + CHECK(contains(s, "79440")); + CHECK(contains(s, "79448")); + // 79440 IS the ms-floored count, so the verdict has to say the floor did not move. + CHECK(contains(s, "floored to the millisecond")); +} + +static void testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt() { + // A render 3 frames short is short, but 79445 is not the floored count — the + // millisecond sentence must not appear, or it would assert a shape that is absent. + CHECK(msFlooredEndFrameCount(0.0, 1.6551724137931001, 48000) != 79445); + const std::string s = + describeBoundsExperiment("custom time bounds", 0.0, 1.6551724137931001, + 79445, 48000); + CHECK(contains(s, "SHORT")); + CHECK(!contains(s, "floored to the millisecond")); +} + +static void testARenderPastTheWindowReadsLong() { + // The whole-item widening, through the verdict: 30 s printed for a 1 s window. + const std::string s = + describeBoundsExperiment("custom time bounds", 5.0, 6.0, 30 * 48000, 48000); + CHECK(contains(s, "LONG")); + CHECK(contains(s, "1440000 frames")); + CHECK(contains(s, "the 48000 the window asks for")); +} + +static void testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch() { + // A window whose end is a whole millisecond has nothing for a floor to take: the + // exact count and the floored count are the same number, so an exact render reads + // EXACT and the millisecond sentence never fires. + CHECK(frameCountFor(0.0, 2.0, 48000) == msFlooredEndFrameCount(0.0, 2.0, 48000)); + const std::string s = + describeBoundsExperiment("time selection", 0.0, 2.0, 96000, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "96000")); + CHECK(!contains(s, "floored to the millisecond")); + // The false positive this window is the shape of: an end-floored render would have + // printed this identical EXACT count, so the line must say this run cannot tell the + // two apart rather than reading EXACT as settled. + CHECK(contains(s, "END edge is UNTESTED")); +} + +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() { + CHECK(contains(describeBoundsExperiment("time selection", 0.0, 1.0, 48000, 48000, + nullptr), + "EXACT")); + CHECK(contains(describeBoundsExperiment("time selection", 0.0, 1.0, 48000, 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 settle the start question: a start carrying its own remainder. + // Whether REAPER floors the start or not, THIS run is the one that shows it. + const std::string s = + describeBoundsExperiment("time selection", 1.0001724, 2.0001724, 48000, 48000); + CHECK(contains(s, "IS tested")); + CHECK(!contains(s, "UNTESTED")); + // A floored start would have printed 48008 frames, not 48000 — so the same line + // reads EXACT here and SHORT/LONG on the floored outcome. + CHECK(frameCountFor(1.000, 2.0001724, 48000) == 48008); + CHECK(contains(s, "EXACT")); + CHECK(contains(describeBoundsExperiment("time selection", 1.0001724, 2.0001724, + 48008, 48000), + "LONG")); +} + +static void testAt44100WhereAMillisecondIsNotAWholeNumberOfFrames() { + // 44.1 kHz: the window is 463 frames, the ms-floored one 441 (both pinned in + // testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames). The verdict has to + // reach the same two numbers at a rate where a millisecond is 44.1 frames. + const std::string exact = + describeBoundsExperiment("time selection", 0.0, 0.0105, 463, 44100); + CHECK(contains(exact, "EXACT")); + CHECK(contains(exact, "44100 Hz")); + + const std::string floored = + describeBoundsExperiment("custom time bounds", 0.0, 0.0105, 441, 44100); + CHECK(contains(floored, "SHORT")); + CHECK(contains(floored, "floored to the millisecond")); +} + +static void testAnUnmeasuredRenderAnswersNothingRatherThanPassing() { + // Auto/Manual are not judged against a frame count, and an empty render has none. + // The line must still print and must not read as a pass — its silence would. + const std::string s = + describeBoundsExperiment("time selection", 0.0, 1.6551724137931001, 0, 0); + CHECK(!s.empty()); + CHECK(contains(s, "NOT JUDGED")); + CHECK(!contains(s, "EXACT")); + CHECK(contains(s, "time selection")); +} + +static void testAnUnnamedChannelStillProducesAReadableLine() { + CHECK(contains(describeBoundsExperiment(nullptr, 0.0, 1.0, 48000, 48000), + "unnamed")); + CHECK(contains(describeBoundsExperiment("", 0.0, 1.0, 48000, 48000), "unnamed")); +} + // --- describeBoundsDrift: the read-back's verdict ------------------------------ static void testBoundsThatReadBackUnchangedDescribeNothing() { @@ -395,6 +603,22 @@ int main() { testOneFrameOfRemainderStillFloors(); testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames(); testASubMillisecondStartWouldNotHideItself(); + testTheTwoLiveShortRendersPinnedAtFullPrecision(); + testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap(); + testOffGridRecognizesASubMillisecondRemainder(); + testAnExactRenderReadsExactAndNamesItsChannel(); + testTheLiveShortfallReadsShortAndNamesTheMillisecondShape(); + testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt(); + testARenderPastTheWindowReadsLong(); + testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch(); + testAWithinToleranceDeltaIsTaggedNotFloorShaped(); + testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel(); + testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict(); + testAnOnGridStartSaysTheStartEdgeIsUntested(); + testAnOffGridStartSaysTheStartEdgeIsTested(); + testAt44100WhereAMillisecondIsNotAWholeNumberOfFrames(); + testAnUnmeasuredRenderAnswersNothingRatherThanPassing(); + testAnUnnamedChannelStillProducesAReadableLine(); testBoundsThatReadBackUnchangedDescribeNothing(); testADriftedEndNamesBothWindowsAndBothCounts(); testTheReportPrintsEnoughDigitsToShowTheDrift(); From bcdf97d6c41889a8a8d7af93e0fd71d74f084537 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 16:21:46 -0400 Subject: [PATCH 08/48] Fix render-bounds EXACT verdict: enumerate floored models instead of trusting grid membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid-ness of an edge was a proxy for "no floor could explain this count," not the test itself — equal remainders on both edges cancel under a full floor. Now checks all three floored models directly and corrects the SHORT/LONG floor-signature docs. --- docs/VERIFICATION.md | 2 +- src/core/capture/render_settings.h | 17 +++-- src/core/capture/render_window.cpp | 66 ++++++++++++---- src/core/capture/render_window.h | 26 +++++-- src/shell/capture/capture.cpp | 9 ++- tests/test_render_window.cpp | 119 +++++++++++++++++++++++++---- 6 files changed, 188 insertions(+), 51 deletions(-) diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 9165e80..6b6f31d 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 — 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. Every capture prints one line beginning `ReaSampler capture -- bounds channel:` — 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: a bare **SHORT**/**LONG** (no tag), or one naming "floored to the millisecond", is the floor's signature — it did not escape this channel. **(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. **EXACT on a window whose END is off the millisecond grid** is the fix — the floor did not reach this channel. **EXACT on a window whose END lands on the grid is NOT conclusive**: the line adds "The END edge is UNTESTED here too" — a floored render prints the identical count by coincidence, so re-run with an off-grid end 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 render source itself derives its own bounds and never consulted the channel — selected-items/razor captures always read this way, so pick a window narrower than the selected item(s) to route through the time-bounded source instead +- [ ] **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 diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 34a627c..cc04cfc 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -145,13 +145,16 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); // naming them apart would assert a render distinction that does not exist. const char* renderSourceLabel(SourceMode mode); -// True for a render source that derives its bounds from content rather than from -// RENDER_BOUNDSFLAG at all: SelectedItems (&32) and RazorArea (&4096) bound -// themselves to the selected items'/areas' own extents (see the &32 inference in -// src/core/capture/CLAUDE.md §Gotchas; RazorArea is read the same way, sharing the -// single-file bit for the same reason). A capture on one of these never consults -// RenderBoundsChannel, so describeBoundsExperiment's verdict must not be read as -// evidence about the channel for it — the caller names the source instead. +// 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 ------------------------------------ diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index 225e18e..8e92b36 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -76,20 +76,30 @@ std::string describeBoundsExperiment(const char* channelLabel, s += ". "; if (bypassingSourceLabel && bypassingSourceLabel[0]) { - s += "NOT JUDGED -- rendered from " + std::string(bypassingSourceLabel) + - ", which derives its bounds from content and never consulted this channel;" - " this capture is not evidence either way about it."; + 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 -- the landed render's frames were never counted against the " + 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 = @@ -101,13 +111,14 @@ std::string describeBoundsExperiment(const char* channelLabel, " 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. - if (delta != 0) { + // 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 -- this channel did not escape the floor."; + " the millisecond -- the shape REAPER's render was measured producing."; } s += isOnMillisecondGrid(reqStart) @@ -117,15 +128,38 @@ std::string describeBoundsExperiment(const char* channelLabel, : " The START edge IS tested here: " + exactly(reqStart) + "s carries a sub-millisecond remainder."; - // An EXACT verdict on an on-grid END is not proof: a channel that floors the end - // would have printed this same count, since floor/ceil/round all leave a grid point - // alone. Without this, EXACT reads as settled when this run could not have told the - // two apart. - if (delta == 0 && isOnMillisecondGrid(reqEnd)) { - s += " The END edge is UNTESTED here too: " + exactly(reqEnd) + - "s is already on the millisecond grid, so a channel that floors the end" - " would have printed this same EXACT count -- re-run over a window whose" - " end is off the grid before reading EXACT as the fix."; + // 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; } diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index eb6df14..5e64d90 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -85,20 +85,32 @@ bool isOnMillisecondGrid(double seconds); // 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: an END that sits on the millisecond grid prints -// the SAME EXACT count whether the channel honored the window or floored it and landed -// back on the grid by coincidence, so that case is called out in the sentence rather -// than left to read as settled — same principle as the existing START-edge caveat. +// 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; a bare SHORT/LONG, or a delta matching -// msFlooredEndFrameCount exactly, is the floor's signature. +// 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. +// 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 diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 0e683be..ccce78f 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -563,10 +563,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // 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 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. + // 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; diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 721e183..184fe18 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -417,10 +417,79 @@ static void testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch() { CHECK(contains(s, "EXACT")); CHECK(contains(s, "96000")); CHECK(!contains(s, "floored to the millisecond")); - // The false positive this window is the shape of: an end-floored render would have - // printed this identical EXACT count, so the line must say this run cannot tell the - // two apart rather than reading EXACT as settled. - CHECK(contains(s, "END edge is UNTESTED")); + // The false positive this window is the shape of: a render that floored either edge + // alone, or both together, would have printed this identical EXACT count (every + // edge here is on the grid) -- the line has to say this run cannot rule any of them + // out rather than reading EXACT as settled. + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors the START edge alone")); + CHECK(contains(s, "floors the END edge alone")); + CHECK(contains(s, "floors START and END together")); +} + +static void testEqualRemaindersCancelUnderAFullFloorEvenOffGrid() { + // C1: a dragged, fixed-length time selection reproduces this. Neither edge sits on + // the millisecond grid (isOnMillisecondGrid is false for both), but the START and + // END frame-rounding remainders are EQUAL (rs == re == 8 frames), so a render that + // floors both edges together lands on the identical count -- the grid predicate on + // either edge alone would have missed this collision entirely. + const double start = 1.0001724, end = 2.0001724; + CHECK(!isOnMillisecondGrid(start)); + CHECK(!isOnMillisecondGrid(end)); + const long long expected = frameCountFor(start, end, 48000); + CHECK(expected == 48000); + // The both-edges-floored render lands on the SAME count as the exact one. + CHECK(frameCountFor(1.000, 2.000, 48000) == expected); + // Neither edge floored ALONE reproduces it -- only the combined floor does. + CHECK(frameCountFor(1.000, end, 48000) != expected); + CHECK(frameCountFor(start, 2.000, 48000) != expected); + + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors START and END together")); + CHECK(!contains(s, "floors the START edge alone")); + CHECK(!contains(s, "floors the END edge alone")); +} + +static void testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd() { + // C1's second live shape: isOnMillisecondGrid reads this END as off-grid, but the + // remainder is under half a frame at 48 kHz, so flooring it doesn't move its frame + // index -- a grid test on the edge alone would still miss this collision. + const double start = 0.0, end = 1.000005; + CHECK(!isOnMillisecondGrid(end)); + const long long expected = frameCountFor(start, end, 48000); + CHECK(expected == 48000); + CHECK(frameCountFor(start, 1.000, 48000) == expected); // the floored-end model matches + + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors the END edge alone")); +} + +static void testALongVerdictNeverCarriesTheFloorSentence() { + // A floor only removes frames, so LONG can never be its signature -- the sentence + // must not appear even though the delta here is a "clean" one-frame LONG. + const std::string s = + describeBoundsExperiment("time selection", 5.0, 6.0, 48001, 48000); + CHECK(contains(s, "LONG")); + CHECK(!contains(s, "floored to the millisecond")); +} + +static void testASubFrameWindowIsNotJudgedNotExact() { + // A window under one frame at this rate rounds to 0 expected frames. A 0-frame + // render against that is a 0-vs-0 coincidence of degenerate inputs, not a match -- + // it must read NOT JUDGED, never EXACT. + const double oneTenthOfAFrame = 1.0 / (48000.0 * 10.0); + const long long expected = frameCountFor(0.0, oneTenthOfAFrame, 48000); + CHECK(expected == 0); + const std::string s = + describeBoundsExperiment("time selection", 0.0, oneTenthOfAFrame, 0, 48000); + CHECK(contains(s, "NOT JUDGED")); + CHECK(!contains(s, "EXACT")); } static void testAWithinToleranceDeltaIsTaggedNotFloorShaped() { @@ -460,10 +529,18 @@ static void testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel() { } static void testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict() { - CHECK(contains(describeBoundsExperiment("time selection", 0.0, 1.0, 48000, 48000, - nullptr), - "EXACT")); - CHECK(contains(describeBoundsExperiment("time selection", 0.0, 1.0, 48000, 48000, ""), + // 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")); } @@ -476,18 +553,24 @@ static void testAnOnGridStartSaysTheStartEdgeIsUntested() { } static void testAnOffGridStartSaysTheStartEdgeIsTested() { - // The run that would settle the start question: a start carrying its own remainder. - // Whether REAPER floors the start or not, THIS run is the one that shows it. + // 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", 1.0001724, 2.0001724, 48000, 48000); + describeBoundsExperiment("time selection", start, end, expected, 48000); CHECK(contains(s, "IS tested")); CHECK(!contains(s, "UNTESTED")); - // A floored start would have printed 48008 frames, not 48000 — so the same line - // reads EXACT here and SHORT/LONG on the floored outcome. - CHECK(frameCountFor(1.000, 2.0001724, 48000) == 48008); CHECK(contains(s, "EXACT")); - CHECK(contains(describeBoundsExperiment("time selection", 1.0001724, 2.0001724, - 48008, 48000), + 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")); } @@ -611,6 +694,10 @@ int main() { testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt(); testARenderPastTheWindowReadsLong(); testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch(); + testEqualRemaindersCancelUnderAFullFloorEvenOffGrid(); + testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd(); + testALongVerdictNeverCarriesTheFloorSentence(); + testASubFrameWindowIsNotJudgedNotExact(); testAWithinToleranceDeltaIsTaggedNotFloorShaped(); testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel(); testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict(); From 8331df2e919714e6fa62b81163e73fcce535d5a2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 16:55:12 -0400 Subject: [PATCH 09/48] 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 10/48] 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: