Refuse every multi-track selected-tracks render, both scopes; make a failed mono collapse observable
This commit is contained in:
+28
-27
@@ -511,39 +511,40 @@ registration and `ingestHandleSectionCommand` move into it and gain unit coverag
|
||||
`ingest.cpp`'s own `command_id`+`gaccel` registration collapses onto
|
||||
`action_registry::registerAction` where the shapes match.
|
||||
|
||||
## A multi-track TRACK capture renders one file per track and lands one of them
|
||||
## The `&128` multi-track output shape is still DAW-unobserved, and a refusal now rests on it
|
||||
|
||||
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** Track scope has always
|
||||
rendered through `RENDER_SETTINGS &128` ("selected tracks via master"), and the
|
||||
ranged item capture now joins it there. The SDK header (~3041) documents the
|
||||
single-file bit `&(4<<16)` for "rendering selected items or razor edits" only, so it
|
||||
does not apply to `&128`: the reading is that N selected tracks produce N files.
|
||||
`capture.cpp` sets `RENDER_PATTERN` to one literal stem and detects success by
|
||||
`std::filesystem::exists`, so N stems collapse onto one name and whichever file
|
||||
survives lands as a successful capture carrying one track's audio.
|
||||
**Context.** The multi-track TRACK capture no longer lands one track's audio under an
|
||||
`Ok`: `renderOffline` refuses every selected-tracks render covering more than one track,
|
||||
both scopes, naming the way out (`render_settings::isMultiTrackStemRender` /
|
||||
`multiTrackRefusalMessage`). What did NOT change is the evidence: the per-track-output
|
||||
reading of `&128` is still INFERRED from the SDK header documenting the single-file bit
|
||||
`&(4<<16)` for item/razor sources only. It has never been observed in a DAW.
|
||||
|
||||
**The wart.** `renderOffline` refuses this shape for the ranged ITEM capture
|
||||
(`render_settings::isMultiTrackRangedItemRender`) because that path was newly routed
|
||||
into it. Track scope with two or more tracks selected has the same exposure and is
|
||||
deliberately untouched — changing a shipped action from "produces a file" to
|
||||
"refuses" is a behavioral-contract change, and it was out of that track's surface.
|
||||
**The wart.** The refusal is therefore as unverified as the defect it closes. If REAPER
|
||||
in fact sums a multi-track `&128` render into the single literal `RENDER_PATTERN`, the
|
||||
refusal costs a working capture — a user who selects two tracks and captures gets a
|
||||
message where a correct summed file used to land.
|
||||
|
||||
**Intended fix.** Not proposed. Three shapes exist and the choice is a product call,
|
||||
not a mechanical one: refuse (matching the item path), render each track and land N
|
||||
bank entries (that is batch capture's meaning, not this action's), or sum the
|
||||
selected tracks into one file (needs a summing render source `&128` does not offer).
|
||||
**Intended fix.** Run the observation in `docs/verify-track-scope-multitrack.md` §3 (a
|
||||
hand-driven Render dialog, source "selected tracks via master", one literal filename, two
|
||||
tracks selected — then count the files REAPER writes). If it comes back "one summed
|
||||
file", the refusal is over-strict for the TRACK scope and should be narrowed back; the
|
||||
ITEM-scope half stays regardless, since a per-item-track render is not the sum the user
|
||||
asked for either way. If it comes back "one file per track", nothing to do and the
|
||||
inference is retired into fact.
|
||||
|
||||
**The constraint the fix MUST handle.** The per-track-output reading of `&128` is
|
||||
INFERRED from the header's single-file wording, never observed in a DAW. Verify it
|
||||
first — including what REAPER actually writes when N stems share one literal
|
||||
`RENDER_PATTERN` — because the answer decides whether this is a defect at all.
|
||||
**The constraint the fix MUST handle.** Narrowing the refusal must keep the ITEM scope
|
||||
refusing, must keep `renderOffline` the single seam (so a recipe replay cannot diverge
|
||||
from a fresh capture), and must not re-open the collapse for any caller that reaches
|
||||
`&128` later — the predicate is keyed on the render source precisely so new callers
|
||||
inherit it.
|
||||
|
||||
**Priority / risk.** Unknown until the DAW check above runs. If the reading is right,
|
||||
a silently-wrong capture on an ordinary two-track selection; if wrong, nothing.
|
||||
**Priority / risk.** Low and bounded either way: the current behavior refuses rather than
|
||||
lands wrong audio, so the cost of being wrong here is a refused capture, not a bad one.
|
||||
|
||||
**Done looks like.** The `&128` multi-track output shape is DAW-confirmed, and track
|
||||
scope either produces defined correct output for a multi-track selection or refuses
|
||||
it with a message naming the reason.
|
||||
**Done looks like.** The `&128` multi-track output shape is DAW-observed and written into
|
||||
`src/shell/capture/CLAUDE.md` as fact rather than inference, and the refusal is either
|
||||
kept as-is or narrowed to the item scope with that observation cited.
|
||||
|
||||
## A `SelectedItems` recipe replays against whatever items are selected then
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# DAW verification — track-scope capture over a multi-track selection
|
||||
|
||||
What a DAW pass must establish for the multi-track track capture, and the exact numbers
|
||||
or strings to read off. Nothing below can be closed by a unit test: every item depends on
|
||||
what REAPER actually does with a render request.
|
||||
|
||||
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
|
||||
load at startup only. Set the docked panel's tail toggle to **None** before every cell;
|
||||
Auto adds an 8 s window and Manual a fixed one, and both would invalidate the frame-count
|
||||
readings.
|
||||
|
||||
**Project to use.** One saved project, project sample rate pinned to 48000. Two audio
|
||||
tracks, `A` and `B`, each holding one item at least 30 s long, with *audibly different*
|
||||
content (a tone on `A`, a drum loop on `B`). One folder track `F` with `A` and `B` as its
|
||||
children, used only in §5.
|
||||
|
||||
---
|
||||
|
||||
## 1. The regression floor — single-track track capture is unchanged
|
||||
|
||||
Select **track `A` only**. Make a time selection from **10.000 s to 12.000 s**. Run
|
||||
*ReaSampler: capture selected track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- A file appears in the project's bank folder, and one new card appears on the panel.
|
||||
- The card's length reads **2.000 s**; its frame count is **96000** (`round(12.0 × 48000)
|
||||
− round(10.0 × 48000)`). The backend refuses the capture with `BoundsMismatch` if the
|
||||
render is more than one frame off that, so a landed capture already proves the number
|
||||
to ±1 — what you are confirming here is that it landed at all.
|
||||
- The REAPER console shows **no** `ReaSampler capture failed:` line.
|
||||
- Track `A` is still the only selected track afterwards.
|
||||
|
||||
Repeat with a **razor area** on `A` over the same 10–12 s span and no time selection: same
|
||||
three readings.
|
||||
|
||||
**This is the byte-identical floor.** If either cell now refuses, the change is wrong —
|
||||
the refusal must fire only above one track.
|
||||
|
||||
## 2. The defect cell — two selected tracks now refuse
|
||||
|
||||
Select **`A` and `B` together**. Time selection 10.000–12.000 s. Run *capture selected
|
||||
track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- The console prints exactly:
|
||||
`ReaSampler capture failed: A track capture renders the selected tracks through the
|
||||
master, and more than one track cannot land as a single file. Capture one track at a
|
||||
time, or route them into a folder/bus track and capture that (a folder's own output is
|
||||
its children summed).`
|
||||
- **No** new card on the panel, and **no** new `.wav` in the bank folder (check the folder
|
||||
directly — a stray file with nothing indexing it would mean the refusal fired too late).
|
||||
- `A` and `B` are both still selected, both still unmuted, and neither track's fader, pan,
|
||||
or FX-bypass state changed. The refusal returns before any guard is constructed, so
|
||||
there should be nothing to restore — this reading is what confirms that.
|
||||
|
||||
Repeat with a **razor area spanning both tracks** and no time selection: identical
|
||||
readings. Note that the track *selection* is what the refusal counts — a razor over two
|
||||
tracks with only `A` selected is a one-track capture and must still succeed (§1).
|
||||
|
||||
## 3. The decisive observation — what `&128` actually writes
|
||||
|
||||
**This is the one that retires an inference, and it is the reason `docs/TODO.md` still
|
||||
carries an entry.** The refusal in §2 rests on reading the SDK header's single-file bit
|
||||
`&(4<<16)` as applying to item/razor sources only, never to `&128` — so N selected tracks
|
||||
are believed to produce N files. That has never been observed.
|
||||
|
||||
Drive REAPER's own Render dialog by hand, with the extension out of the loop:
|
||||
|
||||
1. Select `A` and `B`.
|
||||
2. File → Render. **Source:** *Selected tracks (stems)* — the dialog wording for `&128`.
|
||||
**Bounds:** *Custom time range*, 10.000 to 12.000 s.
|
||||
3. **File name:** a literal stem with **no wildcards at all** — e.g. `stemprobe`. Clear
|
||||
`$track` / `$item` / anything else from the pattern; the extension writes exactly one
|
||||
literal stem, so the probe must too.
|
||||
4. Render to an empty scratch folder.
|
||||
|
||||
Read off — **the file count in that folder**:
|
||||
|
||||
- **Two files** (however REAPER disambiguated them, or one file that visibly got
|
||||
overwritten): the inference holds, the §2 refusal is correct, and the `docs/TODO.md`
|
||||
entry can be closed by writing this observation into `src/shell/capture/CLAUDE.md` as
|
||||
fact.
|
||||
- **One file containing `A` and `B` summed** (confirm by ear, or by nulling it against a
|
||||
master render of the same range with only `A` and `B` unmuted): the inference is wrong,
|
||||
the §2 refusal costs a working capture, and the track-scope half should be narrowed back
|
||||
per the `docs/TODO.md` entry. The item-scope half stays either way.
|
||||
|
||||
Also record **what REAPER named the files** — that decides whether a future correct
|
||||
multi-track capture could ever be built on this source at all.
|
||||
|
||||
## 4. Recapture replays the same answer
|
||||
|
||||
Take a **single-track** track capture that carries provenance (capture a range on `A`
|
||||
whose source item is itself a bank sample, so `detectParent` fires), select its card, and
|
||||
run *re-capture from source*. It must regenerate — same audio, same 96000 frames.
|
||||
|
||||
Then construct the multi-track case: a recorded recipe whose `trackGuids` names two
|
||||
tracks. The reachable way to get one is to have captured it before this change; if no such
|
||||
entry exists in any project, record that this cell was **not exercised** rather than
|
||||
inventing one. When it is exercised, read off:
|
||||
|
||||
- `ReaSampler re-capture failed:` followed by the **same** message text as §2.
|
||||
- The bank entry is untouched — same file, same hash, same card.
|
||||
|
||||
## 5. The way out actually works
|
||||
|
||||
Route `A` and `B` into folder `F`. Select **`F` only**, time selection 10.000–12.000 s,
|
||||
capture track scope.
|
||||
|
||||
Read off: one card, 2.000 s, and the audio contains **both** `A` and `B`. This is what the
|
||||
refusal message tells the user to do, so it has to be true.
|
||||
|
||||
## 6. Realtime still accepts a multi-track selection
|
||||
|
||||
Select `A` and `B`. Run *ReaSampler: capture selected track(s) in realtime* over the same
|
||||
range. Read off: **one** card, and its audio contains both tracks. Realtime taps each
|
||||
source track with a send into one temp track, so it sums where the offline render cannot —
|
||||
the divergence from §2 is deliberate and this cell is what confirms it is real.
|
||||
|
||||
## 7. Mono collapse — what is and is not reachable
|
||||
|
||||
Capture a range on a track whose content is dead-center (a mono source panned center, or
|
||||
a duplicated-channel file). Read off:
|
||||
|
||||
- The panel card reports **1 channel**.
|
||||
- The console shows **no** `the lossless mono collapse ... landed intact, as captured.`
|
||||
line.
|
||||
|
||||
**Not DAW-reachable:** the collapse's *failure* branch. It fires only if the captured file
|
||||
cannot be read, or its temporary rewrite cannot be written or renamed, inside the same
|
||||
call that just rendered the file — there is no manual way to inject that fault between the
|
||||
render and the rename. The branch is covered only at its reporting seam
|
||||
(`tests/test_wav_codec.cpp`, `testCollapseOutcomesReportDistinctly`), and its console line
|
||||
has never been seen in a running REAPER. If you ever do see it, the capture in question is
|
||||
intact and correctly measured — only the size win was lost.
|
||||
@@ -112,11 +112,27 @@ SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||
return SourceMode::SelectedTracks;
|
||||
}
|
||||
|
||||
bool isMultiTrackRangedItemRender(CaptureScope scope, SourceMode mode,
|
||||
int sourceTrackCount) {
|
||||
return scope == CaptureScope::Item
|
||||
&& mode == SourceMode::SelectedTracks
|
||||
&& sourceTrackCount > 1;
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount) {
|
||||
return mode == SourceMode::SelectedTracks && sourceTrackCount > 1;
|
||||
}
|
||||
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope) {
|
||||
switch (scope) {
|
||||
case CaptureScope::Item:
|
||||
return "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.";
|
||||
case CaptureScope::Track:
|
||||
return "A track capture renders the selected tracks through the master, "
|
||||
"and more than one track cannot land as a single file. Capture one "
|
||||
"track at a time, or route them into a folder/bus track and capture "
|
||||
"that (a folder's own output is its children summed).";
|
||||
}
|
||||
// Unreachable for a valid enum; a refusal with no way out is still better than a
|
||||
// silent one, so fail closed to the scope-agnostic half of the message.
|
||||
return "This selection spans more than one track, which cannot land as a single "
|
||||
"file. Capture one track at a time.";
|
||||
}
|
||||
|
||||
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||
|
||||
@@ -124,19 +124,23 @@ enum class CaptureScope {
|
||||
// track ITSELF is deliberately not isolated; see src/shell/capture/CLAUDE.md.
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
|
||||
|
||||
// True for the one render shape that cannot land as a single capture: an item-scope
|
||||
// capture re-sourced to the selected-tracks render (its window is not the item
|
||||
// extent) whose selected items span more than one track. That source is read as
|
||||
// rendering one file per selected track — the single-file bit is documented for
|
||||
// item/razor sources only (SDK header ~3041), which is the whole basis for the
|
||||
// reading and is DAW-unverified. If it holds, N tracks collapse N stems onto one
|
||||
// literal render pattern and whichever file survived would land as a successful
|
||||
// capture carrying one track's audio. The caller refuses instead.
|
||||
// True for the one render shape that cannot land as a single capture: a selected-tracks
|
||||
// render covering more than one track — a ranged item capture whose items span several
|
||||
// tracks, or any multi-track track capture. That source is read as rendering one file
|
||||
// per selected track — the single-file bit is documented for item/razor sources only
|
||||
// (SDK header ~3041), which is the whole basis for the reading and is DAW-unverified.
|
||||
// If it holds, N tracks collapse N stems onto one literal render pattern and whichever
|
||||
// file survived would land as a successful capture carrying one track's audio. The
|
||||
// caller refuses instead.
|
||||
//
|
||||
// Track scope is deliberately NOT covered here even though it renders through the
|
||||
// same source — see src/shell/capture/CLAUDE.md §Gotchas.
|
||||
bool isMultiTrackRangedItemRender(CaptureScope scope, SourceMode mode,
|
||||
int sourceTrackCount);
|
||||
// Scope is deliberately NOT a parameter: the exposure comes from the render SOURCE,
|
||||
// which both scopes reach.
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount);
|
||||
|
||||
// The refusal text for the shape above. Keyed on scope because only the way OUT differs:
|
||||
// an item capture can also widen its range to the items' own extent, which a track
|
||||
// capture has no analog for. Kept beside the predicate so the two read as siblings.
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope);
|
||||
|
||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||
//
|
||||
|
||||
@@ -300,6 +300,16 @@ MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string monoCollapseSuffix(MonoCollapseOutcome outcome) {
|
||||
switch (outcome) {
|
||||
case MonoCollapseOutcome::Declined: return {};
|
||||
case MonoCollapseOutcome::Collapsed: return " (collapsed to mono)";
|
||||
case MonoCollapseOutcome::Failed:
|
||||
return " (mono collapse failed -- left as captured)";
|
||||
}
|
||||
return {}; // unreachable for a valid enum; claim nothing rather than a wrong outcome
|
||||
}
|
||||
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
|
||||
@@ -117,6 +117,21 @@ struct MonoCollapse {
|
||||
// including the bext/source-position consequence beyond hashing.
|
||||
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
// How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own
|
||||
// "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did
|
||||
// not land. The capture is intact and correctly measured in every case — only the report
|
||||
// tells them apart, which is why the two must not share one value.
|
||||
enum class MonoCollapseOutcome {
|
||||
Declined,
|
||||
Collapsed,
|
||||
Failed,
|
||||
};
|
||||
|
||||
// The capture message's collapse suffix — empty for Declined, so a capture that had
|
||||
// nothing to collapse reads exactly as it did before the collapse existed. Shared by
|
||||
// both backends so one outcome cannot be reported two ways.
|
||||
std::string monoCollapseSuffix(MonoCollapseOutcome outcome);
|
||||
|
||||
// --- Content identity (dedup hashes) -----------------------------------------
|
||||
|
||||
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -243,40 +243,58 @@ static void testRangedItemScopeRendersTimeBounded() {
|
||||
CHECK(fxBypassPlanFor(CaptureScope::Item).bypassSelfFx);
|
||||
}
|
||||
|
||||
static void testMultiTrackRangedItemRenderIsNamedForRefusal() {
|
||||
// The one shape that cannot land: a ranged item capture (item scope re-sourced to
|
||||
// the selected-tracks render) whose items span more than one track. That source
|
||||
// is INFERRED to render one file per track with no single-file bit available
|
||||
// (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that inference
|
||||
// rests on), so N stems would collapse onto one render pattern and one track's
|
||||
// audio would land as a successful capture.
|
||||
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
SourceMode::SelectedTracks, 2));
|
||||
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
SourceMode::SelectedTracks, 7));
|
||||
static void testMultiTrackStemRenderIsNamedForRefusal() {
|
||||
// The one shape that cannot land: a selected-tracks render over more than one
|
||||
// track. That source is INFERRED to render one file per track with no single-file
|
||||
// bit available (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that
|
||||
// inference rests on), so N stems would collapse onto one render pattern and one
|
||||
// track's audio would land as a successful capture.
|
||||
CHECK(isMultiTrackStemRender(SourceMode::SelectedTracks, 2));
|
||||
CHECK(isMultiTrackStemRender(SourceMode::SelectedTracks, 7));
|
||||
|
||||
// One track is the whole point of the re-source — it must still render.
|
||||
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
SourceMode::SelectedTracks, 1));
|
||||
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
SourceMode::SelectedTracks, 0));
|
||||
// One track is the common case for BOTH scopes — it must still render. This is the
|
||||
// regression floor for the plain single-track track capture.
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::SelectedTracks, 1));
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::SelectedTracks, 0));
|
||||
|
||||
// A full-extent item capture keeps the selected-items source, whose single-file
|
||||
// bit already sums a multi-track item selection into one file.
|
||||
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
SourceMode::SelectedItems, 3));
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::SelectedItems, 3));
|
||||
// Razor's single-file bit does the same; master mix is one file by definition.
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::RazorArea, 3));
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::MasterMix, 3));
|
||||
// Realtime never reaches the offline render at all (sends sum into one temp track).
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::Realtime, 3));
|
||||
|
||||
// Track scope renders through the same source and is deliberately untouched here
|
||||
// — that per-track output predates the item re-source and is filed in docs/TODO.md.
|
||||
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Track,
|
||||
SourceMode::SelectedTracks, 3));
|
||||
// The predicate is reachable from the mappings it guards: BOTH the ranged item
|
||||
// capture's source mode and the track scope's resolve to the one it names, while a
|
||||
// full-extent item capture does not.
|
||||
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, false), 2));
|
||||
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Track, false), 2));
|
||||
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Track, true), 2));
|
||||
CHECK(!isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, true), 2));
|
||||
}
|
||||
|
||||
// The predicate is reachable from the mapping it guards: the ranged item capture's
|
||||
// own source mode is the one it names.
|
||||
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
sourceModeForScope(CaptureScope::Item, false), 2));
|
||||
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||
sourceModeForScope(CaptureScope::Item, true), 2));
|
||||
static void testRefusalMessagesAreSiblingsWithDistinctExits() {
|
||||
const std::string item = multiTrackRefusalMessage(CaptureScope::Item);
|
||||
const std::string track = multiTrackRefusalMessage(CaptureScope::Track);
|
||||
|
||||
// Both name the same reason — a shape that cannot land as one file — so a user who
|
||||
// hits the mistake in either scope reads one story, not two.
|
||||
CHECK(item.find("single file") != std::string::npos);
|
||||
CHECK(track.find("single file") != std::string::npos);
|
||||
|
||||
// And both name a way out. The shared one is "one track at a time"; each scope then
|
||||
// adds the exit only it has (widen the range / capture the folder).
|
||||
CHECK(item.find("one track's items at a time") != std::string::npos);
|
||||
CHECK(item.find("range match the items' extent") != std::string::npos);
|
||||
CHECK(track.find("one track at a time") != std::string::npos);
|
||||
CHECK(track.find("folder") != std::string::npos);
|
||||
|
||||
// Distinct texts: the track message must not be the item message's wording about
|
||||
// items and ranges, which would misdescribe what the user actually did.
|
||||
CHECK(item != track);
|
||||
CHECK(track.find("selected items") == std::string::npos);
|
||||
}
|
||||
|
||||
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
|
||||
@@ -376,7 +394,8 @@ int main() {
|
||||
testRazorUnionBounds();
|
||||
testScopeSourceModes();
|
||||
testRangedItemScopeRendersTimeBounded();
|
||||
testMultiTrackRangedItemRenderIsNamedForRefusal();
|
||||
testMultiTrackStemRenderIsNamedForRefusal();
|
||||
testRefusalMessagesAreSiblingsWithDistinctExits();
|
||||
testRangeInference();
|
||||
testItemScopeBypassesEverythingButTake();
|
||||
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
||||
|
||||
@@ -775,6 +775,24 @@ static void testCollapsePreservesQuietNaNBitPattern() {
|
||||
if (!pcm.empty()) CHECK(bitsFromFloat(pcm[0]) == kQuietNaNBits);
|
||||
}
|
||||
|
||||
// The file-side collapse has three outcomes, and a genuine I/O failure once reported
|
||||
// identically to "the channels differ" — a stereo file landing under a plain Ok. The
|
||||
// report is where they must part: Declined stays silent (a capture with nothing to
|
||||
// collapse reads as it always did), and the other two say different things.
|
||||
static void testCollapseOutcomesReportDistinctly() {
|
||||
const std::string declined = monoCollapseSuffix(MonoCollapseOutcome::Declined);
|
||||
const std::string collapsed = monoCollapseSuffix(MonoCollapseOutcome::Collapsed);
|
||||
const std::string failed = monoCollapseSuffix(MonoCollapseOutcome::Failed);
|
||||
|
||||
CHECK(declined.empty());
|
||||
CHECK(!collapsed.empty());
|
||||
CHECK(!failed.empty());
|
||||
CHECK(collapsed != failed);
|
||||
// The failure must read as a failure, not as a quieter success.
|
||||
CHECK(failed.find("failed") != std::string::npos);
|
||||
CHECK(collapsed.find("failed") == std::string::npos);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testParseCanonicalStereo();
|
||||
testParseMonoAndLeadingChunk();
|
||||
@@ -810,6 +828,7 @@ int main() {
|
||||
testCollapseDeclinesOnUnparseableBytes();
|
||||
testCollapseChangesContentHash();
|
||||
testCollapsePreservesQuietNaNBitPattern();
|
||||
testCollapseOutcomesReportDistinctly();
|
||||
|
||||
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
||||
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
Reference in New Issue
Block a user