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.
This commit is contained in:
2026-08-02 16:55:12 -04:00
parent d85e50c143
commit 8331df2e91
14 changed files with 205 additions and 862 deletions
+6 -6
View File
@@ -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,
+21 -121
View File
@@ -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<double>(renderBoundsFlagFor(kBoundsChannel)), true);
static_cast<double>(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<double>(tail.tailFlag), true);
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
@@ -532,33 +508,6 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
static_cast<double>(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<double>(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;
+2 -2
View File
@@ -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
-2
View File
@@ -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<long long>(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
-7
View File
@@ -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