capture: refuse the multi-track ranged item render, silence the track's children and receives for it, and gate every exact-bounds capture on its frame count

This commit is contained in:
2026-08-01 20:58:33 -04:00
parent 5e3ea6c851
commit 7dc80e7a4f
20 changed files with 525 additions and 27 deletions
+23 -1
View File
@@ -25,6 +25,18 @@ detail not covered there:
resolved range (`core/capture/render_window`) and hands the answer to
`sourceModeForScope` on `ResolvedSource`. Why, in
`src/core/capture/CLAUDE.md`.
- **A ranged item capture isolates TRACKS, not ITEMS.** Routing it through the
selected-tracks source widens what the render hears, and the two widenings are
answered differently. Folder children and receives are cut for the render's
duration (`render_isolation`) because they are tracks, and a recipe carrying
tracks can recompute that plan at replay time. An overlapping item on the source
track itself is NOT isolated: the recipe stores tracks and a range, never item
GUIDs, so a mute plan over items could not be replayed and the capture would stop
reproducing itself. Do not "fix" the second by muting items.
- **`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.
- **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,
@@ -41,7 +53,8 @@ detail not covered there:
- `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`.
- `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).
- `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; a ranged item capture and re-capture-from-source both name tracks the user has not selected.
- `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).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, 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 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. 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". 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.
- `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.
@@ -60,3 +73,12 @@ detail not covered there:
- `capture` and `capture_realtime_shell` deliberately share NO common interface with
each other (the former `ICaptureBackend` was removed) — do not reintroduce one
without a real second polymorphic call site.
- **The selected-tracks render (`&128`) is read as emitting one file per selected
track** — the single-file bit is documented for item/razor sources only (SDK header
~3041), and that is the whole basis for the reading; it is DAW-unverified. If it
holds, then since `RENDER_PATTERN` is one literal stem and success is a file-exists
check, N tracks would land one track's audio as a successful capture.
`renderOffline` refuses that shape for the RANGED ITEM capture only
(`render_settings::isMultiTrackRangedItemRender`). Track scope renders through the
same source with the same exposure and is deliberately untouched here — filed in
`docs/TODO.md`.
+30
View File
@@ -32,6 +32,7 @@
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
#include "core/util/file_bytes.h"
#include "core/capture/render_settings.h"
#include "core/capture/render_window.h" // frameCountFor — the exact-bounds number
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -394,6 +395,35 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result;
}
// Exact bounds, made structural: with no tail requested the file must contain
// exactly the requested window's frames, so a source mode that silently widened
// the render fails loudly here instead of landing as a successful capture. Auto
// and Manual add frames by design and are skipped. UNVERIFIED: that REAPER
// resolves the window's two edges to frame indices the same way frameCountFor
// does — a DAW pass decides whether this equality is exact or off by a frame.
// (The file is read again by stampCaptureSample below; the duplicate read is a
// once-per-capture cost on an already-warm file.)
if (request.tailMode == TailMode::None) {
const WavLayout layout =
parseWavLayout(util::readFileBytes(expectedPath));
const long long expectedFrames = layout.valid
? frameCountFor(request.startSeconds, request.endSeconds,
static_cast<int>(layout.sampleRate))
: 0;
const long long actualFrames = static_cast<long long>(layout.frameCount());
if (expectedFrames > 0 && actualFrames != expectedFrames) {
result.status = CaptureStatus::BoundsMismatch;
result.message = "Render produced " + std::to_string(actualFrames) +
" frames but the requested range is " +
std::to_string(expectedFrames) + " at " +
std::to_string(layout.sampleRate) +
" Hz -- the render did not honor the requested bounds. "
"Nothing was added to the bank; the file is at: " +
expectedPath;
return result;
}
}
// Record the request's own bounds (exact) rather than re-measuring the file.
Sample s;
// Same uniqueTag that named the file — calling makeUniqueTag() again could
+2
View File
@@ -71,6 +71,8 @@ enum class CaptureStatus {
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
RenderFailed, // the render action ran but produced no output file
TransportBusy, // realtime backend: transport already playing/recording — refused
MultiTrackRange, // a ranged item capture whose items span >1 track — would render N files
BoundsMismatch, // the rendered file's frame count is not the requested window's
};
struct CaptureResult {
+28 -1
View File
@@ -17,6 +17,7 @@
#include "shell/capture/insert.h" // runInsert / InsertRequest
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
#include "shell/capture/render_selection.h" // RenderTrackSelection
#include "shell/capture/render_isolation.h" // UpstreamIsolation
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
@@ -181,15 +182,41 @@ CaptureResult renderOffline(CaptureScope scope,
const std::vector<MediaTrack*>& sourceTracks,
const CaptureRequest& req)
{
// Refused BEFORE anything is touched, so the refusal path has nothing to
// restore. This is the seam BOTH a fresh capture and a recipe replay cross, so
// neither can land the multi-stem render the predicate names.
if (isMultiTrackRangedItemRender(scope, req.sourceMode,
static_cast<int>(sourceTracks.size())))
{
CaptureResult refused;
refused.status = CaptureStatus::MultiTrackRange;
refused.message =
"This range is narrower than the selected items, so it renders through "
"their tracks -- and those items span more than one track, which renders "
"one file per track. Capture one track's items at a time, or make the "
"range match the items' extent.";
return refused;
}
ReaProject* proj = EnumProjects(-1, nullptr, 0);
// A selected-tracks render prints the DAW's track selection, so the request's
// own tracks must BE that selection for the render — the plain track scope
// already resolved them from the live selection (identity), but a ranged item
// capture and a re-capture-from-source both name tracks the user has not
// selected. Both guards outlive the render call and restore on every path.
// selected. Every guard below outlives the render call and restores on every path.
std::optional<RenderTrackSelection> selection;
std::optional<UpstreamIsolation> isolation;
if (req.sourceMode == SourceMode::SelectedTracks)
{
selection.emplace(sourceTracks);
// An ITEM capture routed through the tracks source would otherwise print
// everything upstream of the track — folder children, receives — which the
// selected-items source excluded. The refusal above is what makes the single
// source track here the whole set. Track scope is left alone: a folder
// parent's own output IS its children summed.
if (scope == CaptureScope::Item && !sourceTracks.empty())
isolation.emplace(sourceTracks.front());
}
FxBypassGuard fxGuard(scope, sourceTracks, proj);
OfflineRenderBackend backend;
return backend.capture(req);
+86
View File
@@ -0,0 +1,86 @@
// render_isolation.cpp — see the header.
//
// Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern.
#include "shell/capture/render_isolation.h"
#include <cstddef>
#include "core/capture/track_topology.h" // directChildIndices
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetTrackNumSends
#define REAPERAPI_WANT_GetTrackSendInfo_Value
#define REAPERAPI_WANT_SetTrackSendInfo_Value
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
namespace {
// GetTrackNumSends / *TrackSendInfo_Value category: < 0 selects the RECEIVE list
// (SDK header ~3644, ~3676).
constexpr int kReceivesCategory = -1;
} // namespace
UpstreamIsolation::UpstreamIsolation(MediaTrack* track)
: track_(track)
{
if (!track_) return;
// One pass over the track list for both the depth deltas and this track's index;
// the pure walk turns the flat delta list into the direct-child set. The master
// is absent from GetTrack's index space (it is reached only via GetMasterTrack)
// and has no send-to-parent to cut, so nothing is missed by ignoring it.
const int total = CountTracks(nullptr);
std::vector<MediaTrack*> tracks;
std::vector<int> depths;
tracks.reserve(static_cast<std::size_t>(total < 0 ? 0 : total));
depths.reserve(static_cast<std::size_t>(total < 0 ? 0 : total));
int parentIndex = -1;
for (int i = 0; i < total; ++i)
{
MediaTrack* tr = GetTrack(nullptr, i);
tracks.push_back(tr);
depths.push_back(tr ? static_cast<int>(
GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"))
: 0);
if (tr == track_) parentIndex = i;
}
for (int idx : directChildIndices(depths, parentIndex))
{
MediaTrack* child = tracks[static_cast<std::size_t>(idx)];
if (!child) continue;
// B_MAINSEND: "track sends audio to parent" (SDK header ~2238/~2957).
children_.push_back({child, GetMediaTrackInfo_Value(child, "B_MAINSEND")});
SetMediaTrackInfo_Value(child, "B_MAINSEND", 0.0);
}
const int receives = GetTrackNumSends(track_, kReceivesCategory);
receiveMutes_.reserve(static_cast<std::size_t>(receives < 0 ? 0 : receives));
for (int i = 0; i < receives; ++i)
{
receiveMutes_.push_back(
GetTrackSendInfo_Value(track_, kReceivesCategory, i, "B_MUTE"));
SetTrackSendInfo_Value(track_, kReceivesCategory, i, "B_MUTE", 1.0);
}
}
UpstreamIsolation::~UpstreamIsolation()
{
for (std::size_t i = 0; i < receiveMutes_.size(); ++i)
SetTrackSendInfo_Value(track_, kReceivesCategory, static_cast<int>(i),
"B_MUTE", receiveMutes_[i]);
for (const ChildSend& c : children_)
SetMediaTrackInfo_Value(c.track, "B_MAINSEND", c.mainSend);
}
} // namespace reasampler::capture
+42
View File
@@ -0,0 +1,42 @@
#pragma once
// The transient silencing a ranged ITEM render needs: REAPER's selected-tracks
// source prints everything upstream of the track — its folder children and its
// receives — which an item capture must not hear. Stack RAII, restored on every
// path. The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT.
#include <vector>
#include "shell/capture/capture.h" // MediaTrack fwd
namespace reasampler::capture {
// RAII: for one render, cuts every route by which audio that is not `track`'s own
// items reaches `track` — each direct folder child's send-to-parent (B_MAINSEND) and
// each of `track`'s own receives (that receive's B_MUTE). Both are per-route levers,
// so a child or a sender keeps its FX, its fader, and its routing elsewhere intact.
// Every touched value is restored on every exit path; nothing is created or removed,
// so the project is unchanged afterwards (non-destructive invariant).
//
// An overlapping item on `track` ITSELF is deliberately NOT isolated — the reason is
// in src/shell/capture/CLAUDE.md.
class UpstreamIsolation
{
public:
explicit UpstreamIsolation(MediaTrack* track);
~UpstreamIsolation();
UpstreamIsolation(const UpstreamIsolation&) = delete;
UpstreamIsolation& operator=(const UpstreamIsolation&) = delete;
private:
struct ChildSend {
MediaTrack* track = nullptr;
double mainSend = 0.0; // original B_MAINSEND
};
MediaTrack* track_ = nullptr;
std::vector<ChildSend> children_;
std::vector<double> receiveMutes_; // original B_MUTE, indexed by receive index
};
} // namespace reasampler::capture
+5 -3
View File
@@ -18,9 +18,11 @@ namespace reasampler::capture {
namespace {
// Deselect every track in GetTrack's index space (which excludes the master, SDK
// ~2634), then select exactly `tracks` — so the resulting selection is the set,
// not the set unioned with whatever was already selected.
// Deselect every track in GetTrack's index space, then select exactly `tracks` — so
// the resulting selection is the set, not the set unioned with whatever was already
// selected. That index space holds no master track (the master is reached only via
// GetMasterTrack); the header states this nowhere, so treat it as behavior rather
// than a documented guarantee.
void selectOnly(const std::vector<MediaTrack*>& tracks)
{
const int total = CountTracks(nullptr);
+5 -3
View File
@@ -86,9 +86,11 @@ bool collectSelectedItemTracks(ResolvedSource& out,
return !out.sourceTracks.empty();
}
// 0 when the project never pinned a rate (and nominal unless PROJECT_SRATE_USE is
// set, SDK ~3064) — passed through as "unknown", which the pure window comparison
// handles by falling back to exact equality.
// The header says only that PROJECT_SRATE is IGNORED unless PROJECT_SRATE_USE is set
// (SDK ~3064); that it READS 0 on a project which never pinned a rate is an
// inference, unverified. Either way a non-positive answer is passed through as
// "unknown", which the pure window comparison handles by falling back to exact
// equality.
int projectSampleRate()
{
ReaProject* proj = EnumProjects(-1, nullptr, 0);