Ψ-W2-T2: collapse a capture whose channels are bit-identical to one lossless mono channel, index value measured off the landed file

This commit is contained in:
2026-08-01 21:46:55 -04:00
parent 09d64c9f46
commit 4fa3c1dd15
12 changed files with 312 additions and 11 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ detail not covered there:
## Modules
- `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`.
- `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`. It also owns the two file-side steps both backends share: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree.
- `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, 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`).
+30 -2
View File
@@ -201,11 +201,27 @@ std::string makeUniqueTag(const std::string& prefix) {
std::to_string(++counter);
}
void collapseCapturedFileToMono(const std::string& absolutePath) {
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
if (bytes.empty()) return;
const MonoCollapse collapse = collapseToMono(bytes);
if (!collapse.collapsed) return;
// One truncating write — the same shape, and the same accepted mid-write residual,
// as the realtime Auto-tail trim (capture_realtime_finalize.cpp).
std::ofstream out(absolutePath, std::ios::binary | std::ios::trunc);
if (!out) return;
out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
static_cast<std::streamsize>(collapse.bytes.size()));
}
void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath) {
// Track GUIDs + channel count: echoed from the request (the caller resolved
// the selection; the backends stay source-agnostic).
// Track GUIDs echoed from the request (the caller resolved the selection; the
// backends stay source-agnostic). channelCount starts at the request value only
// as the fallback for an unparseable file — the produced FILE overrides it below.
s.trackGuids = req.trackGuids;
s.channelCount = req.channelCount;
@@ -238,6 +254,10 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
if (!fileBytes.empty()) {
s.contentHash = hashWavContent(fileBytes);
// The one authority for the entry's channel count is the file's own `fmt`
// — never the render request, which asks for 2 on every capture path.
const WavLayout layout = parseWavLayout(fileBytes);
if (layout.valid) s.channelCount = static_cast<int>(layout.channelCount);
}
}
@@ -447,6 +467,14 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
}
}
// Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures
// REAPER's own render against the requested window, so nothing of ours may sit
// between the render and that measurement, and a refusal must delete the
// 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.
collapseCapturedFileToMono(expectedPath);
// 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
+13 -2
View File
@@ -53,6 +53,9 @@ struct CaptureRequest {
// 0 sampleRate => follow project rate.
int sampleRate = 0;
// What the RENDER is asked for (RENDER_CHANNELS / the realtime record mode), not
// what the capture lands as: a dual-mono render is collapsed to 1 channel after
// the fact, and the Sample's count comes from the produced file.
int channelCount = 2;
WavBitDepth bitDepth = WavBitDepth::Float32;
@@ -100,8 +103,16 @@ public:
// backend's family marker ("" offline, "rt-" realtime).
std::string makeUniqueTag(const std::string& prefix);
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
// Rewrites a just-captured WAV in place as a 1-channel file when its channels are
// 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.
void 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`; the request
// value only as the fallback for a file that cannot be parsed), resolved
// sampleRate (request rate, else PROJECT_SRATE
// from `rateProj`), captureTempo, the capture-start time signature
// (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
// active project, realtime pins the record's own project), the WAV-aware
@@ -184,6 +184,10 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
request.endSeconds);
}
// 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.
collapseCapturedFileToMono(destPath);
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
RecordedCapture cap;
cap.relativePath = paths.relativePath;
@@ -194,7 +198,9 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
cap.wetDry = request.wetDry;
cap.displayName = request.baseName;
cap.trackGuids = request.trackGuids;
cap.channelCount = request.channelCount;
// channelCount deliberately left unset here: stampCaptureSample measures it from
// the file below. Echoing the request was this path's own defect — it parsed the
// recorded layout for the trim and still reported the requested 2.
result.status = CaptureStatus::Ok;
result.sample = sampleFromRecordedCapture(cap);
+3 -2
View File
@@ -148,8 +148,9 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// no-play — no crash, no retry loop.
if (const SelectedSample* sel = findRef(refs, selId)) {
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
// count (always 2 for extension captures; mono only for ingest-imported mono files).
// An unknown count (0) or explicit user choice keeps the mode.
// count — 1 for an ingested mono file or a capture whose channels came out
// bit-identical and collapsed, 2 otherwise. An unknown count (0) or an explicit
// user choice keeps the mode.
{
std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_,