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.
This commit is contained in:
2026-08-02 15:56:57 -04:00
parent a0fd931dcb
commit 5f971e60cd
14 changed files with 750 additions and 81 deletions
+8 -2
View File
@@ -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.
+36 -3
View File
@@ -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:
+53 -7
View File
@@ -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
+70 -3
View File
@@ -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) {
+55 -8
View File
@@ -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 <string>
@@ -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
+8 -1
View File
@@ -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,
+108 -13
View File
@@ -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<double>(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<double>(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<double>(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;
+2 -1
View File
@@ -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
+10 -9
View File
@@ -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<long long>(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;
+7
View File
@@ -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