Refuse every multi-track selected-tracks render, both scopes; make a failed mono collapse observable
This commit is contained in:
@@ -74,11 +74,17 @@ detail not covered there:
|
||||
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`.
|
||||
track** — the single-file bit `&(4<<16)` 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 EVERY multi-track render through that source
|
||||
(`render_settings::isMultiTrackStemRender`) — the ranged item capture and the plain
|
||||
track capture alike, each with its own way out
|
||||
(`render_settings::multiTrackRefusalMessage`). The refusal is keyed on the render
|
||||
SOURCE and not on the scope, so a future caller that reaches `&128` inherits it.
|
||||
Re-opening a multi-track track capture needs the DAW check in
|
||||
`docs/verify-track-scope-multitrack.md` to come back the other way first.
|
||||
- **Realtime is the one capture path that accepts a multi-track selection**, and it is
|
||||
correct to: its per-source-track sends sum in the one temp track, which is a real mix
|
||||
rather than a stem collapse. The offline refusal above does not apply to it.
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
#define REAPERAPI_WANT_Main_OnCommand
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
@@ -228,12 +229,30 @@ CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
||||
return composeCaptureName(in);
|
||||
}
|
||||
|
||||
bool collapseCapturedFileToMono(const std::string& absolutePath) {
|
||||
namespace {
|
||||
|
||||
// One console line per genuine collapse failure. The capture itself is intact and was
|
||||
// measured after this step, so the failure costs only the size win — but silence here is
|
||||
// what made a failed rewrite read exactly like a legitimately stereo capture.
|
||||
void reportCollapseFailure(const std::string& absolutePath, const char* what) {
|
||||
ShowConsoleMsg(("ReaSampler capture: the lossless mono collapse " + std::string(what) +
|
||||
" -- " + absolutePath +
|
||||
" landed intact, as captured.\n").c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath) {
|
||||
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
|
||||
if (bytes.empty()) return false;
|
||||
if (bytes.empty()) {
|
||||
// Failed, not Declined: the read that would have decided never happened, so
|
||||
// "the channels differ" is a claim this path cannot make.
|
||||
reportCollapseFailure(absolutePath, "could not read the captured file");
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
|
||||
const MonoCollapse collapse = collapseToMono(bytes);
|
||||
if (!collapse.collapsed) return false;
|
||||
if (!collapse.collapsed) return MonoCollapseOutcome::Declined;
|
||||
|
||||
// Sibling temp + rename, NOT an in-place truncating write: this runs unconditionally
|
||||
// on the deterministic offline path (which never reopened its render for write before
|
||||
@@ -245,7 +264,10 @@ bool collapseCapturedFileToMono(const std::string& absolutePath) {
|
||||
const std::string tempPath = absolutePath + ".moncollapse.tmp";
|
||||
{
|
||||
std::ofstream out(tempPath, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return false;
|
||||
if (!out) {
|
||||
reportCollapseFailure(absolutePath, "could not open its temporary file");
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
|
||||
static_cast<std::streamsize>(collapse.bytes.size()));
|
||||
const bool wroteOk = static_cast<bool>(out);
|
||||
@@ -253,16 +275,18 @@ bool collapseCapturedFileToMono(const std::string& absolutePath) {
|
||||
if (!wroteOk) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(tempPath, ec);
|
||||
return false;
|
||||
reportCollapseFailure(absolutePath, "could not write the rebuilt file");
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
}
|
||||
std::error_code ec;
|
||||
std::filesystem::rename(tempPath, absolutePath, ec);
|
||||
if (ec) {
|
||||
std::filesystem::remove(tempPath, ec); // don't leave litter on a failed rename
|
||||
return false;
|
||||
reportCollapseFailure(absolutePath, "could not replace the captured file");
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
return true;
|
||||
return MonoCollapseOutcome::Collapsed;
|
||||
}
|
||||
|
||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
@@ -473,11 +497,13 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// (within a tolerance, see below) the requested window's frames, so a source
|
||||
// mode that silently widened the render fails loudly here instead of landing as
|
||||
// a successful capture. Auto and Manual add frames by design and are skipped.
|
||||
// (The file is read again by stampCaptureSample below; the duplicate read is a
|
||||
// once-per-capture cost on an already-warm file.) A bounded/header-only read is
|
||||
// not a clean substitute: parseWavLayout only marks the data chunk valid when
|
||||
// the buffer holds the chunk's FULL declared body (bodyInBounds), so a truncated
|
||||
// read would read as invalid here on every real capture, not just malformed ones.
|
||||
// (The landed file is read three times on this path — this gate, the mono collapse,
|
||||
// and stampCaptureSample — plus one rewrite when the collapse fires; a
|
||||
// once-per-capture cost on an already-warm file, judged acceptable.) A
|
||||
// bounded/header-only read is not a clean substitute: parseWavLayout only marks the
|
||||
// data chunk valid when the buffer holds the chunk's FULL declared body
|
||||
// (bodyInBounds), so a truncated read would read as invalid here on every real
|
||||
// capture, not just malformed ones.
|
||||
if (request.tailMode == TailMode::None) {
|
||||
const WavLayout layout =
|
||||
parseWavLayout(util::readFileBytes(expectedPath));
|
||||
@@ -525,7 +551,8 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// renderer's file rather than one this step had already rewritten. The collapse
|
||||
// preserves the frame count, so the two are order-independent in outcome — only
|
||||
// in what each is measuring.
|
||||
const bool collapsedToMono = collapseCapturedFileToMono(expectedPath);
|
||||
const MonoCollapseOutcome collapseOutcome =
|
||||
collapseCapturedFileToMono(expectedPath);
|
||||
|
||||
// Record the request's own bounds (exact) rather than re-measuring the file.
|
||||
Sample s;
|
||||
@@ -553,8 +580,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
result.message = "Captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath +
|
||||
(collapsedToMono ? " (collapsed to mono)" : "");
|
||||
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/capture/capture_name.h" // CaptureName — label + file-stem base
|
||||
#include "core/capture/render_settings.h" // TailMode — the three-state tail contract
|
||||
#include "core/capture/wav_codec.h" // MonoCollapseOutcome — the collapse's report
|
||||
|
||||
// Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these.
|
||||
class MediaTrack;
|
||||
@@ -85,7 +86,7 @@ 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
|
||||
MultiTrackSelection,// a selected-tracks render over >1 track — would render N files
|
||||
BoundsMismatch, // the rendered file's frame count is not the requested window's
|
||||
};
|
||||
|
||||
@@ -126,9 +127,10 @@ CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
||||
// bit-identical (the pure `collapseToMono` decides). Every other file is left
|
||||
// untouched, byte for byte, so the not-collapsed path is exactly what the backend
|
||||
// produced. Must run BEFORE stampCaptureSample, which measures the landed file.
|
||||
// Returns whether the file was actually rewritten (collapsed AND the write landed) —
|
||||
// callers use it to make the collapse observable in the reported CaptureResult.
|
||||
bool collapseCapturedFileToMono(const std::string& absolutePath);
|
||||
// A Failed outcome is ALSO logged to the console here, because a successful capture's
|
||||
// CaptureResult::message is not printed by any caller — the return value alone would
|
||||
// leave a genuine I/O failure indistinguishable from a legitimately stereo capture.
|
||||
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath);
|
||||
|
||||
// Stamps the metadata shared by both backends onto `s`: trackGuids (echoed from the
|
||||
// request) + channelCount (measured from the produced file's `fmt`; 0/unknown as the
|
||||
|
||||
@@ -184,17 +184,14 @@ CaptureResult renderOffline(CaptureScope scope,
|
||||
{
|
||||
// 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())))
|
||||
// neither can land the multi-stem render the predicate names — and both scopes
|
||||
// reach it, so a track capture and a ranged item capture refuse alike.
|
||||
if (isMultiTrackStemRender(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 this "
|
||||
"shape cannot land as a single file. Capture one track's items at a "
|
||||
"time, or make the range match the items' extent.";
|
||||
refused.status = CaptureStatus::MultiTrackSelection;
|
||||
refused.message = multiTrackRefusalMessage(scope);
|
||||
return refused;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
|
||||
// Channel-domain rewrite, after the frame-domain trim so it acts on the final
|
||||
// frame set; it preserves the frame count, so the trimmed length above still holds.
|
||||
const bool collapsedToMono = collapseCapturedFileToMono(destPath);
|
||||
const MonoCollapseOutcome collapseOutcome = collapseCapturedFileToMono(destPath);
|
||||
|
||||
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||
RecordedCapture cap;
|
||||
@@ -225,8 +225,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
paths.relativePath +
|
||||
(collapsedToMono ? " (collapsed to mono)" : "");
|
||||
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@
|
||||
// TAP: the hidden temp track receives a send FROM each selected source track
|
||||
// (CreateTrackSend(source, temp)) and records its own output (B_MAINSEND=0, so
|
||||
// it never sums back into the master — no feedback, no monitoring double).
|
||||
// Multiple selected tracks sum in the one temp track, matching how offline
|
||||
// track scope handles a multi-track selection.
|
||||
// Multiple selected tracks sum in the one temp track — a real mix, which is why
|
||||
// realtime accepts a multi-track selection where the offline track scope refuses
|
||||
// it (that render source cannot express a sum; see shell/capture/CLAUDE.md).
|
||||
//
|
||||
// Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0
|
||||
// (post-fader), which taps the source track after its own FX/fader/pan — its
|
||||
|
||||
Reference in New Issue
Block a user