Merge Ψ-W2-T2: a bit-identical capture collapses to one lossless mono channel
# Conflicts: # src/shell/capture/CLAUDE.md # src/shell/capture/capture.cpp # src/shell/capture/capture.h
This commit is contained in:
@@ -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, in this order: `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. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
|
||||
- `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). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
|
||||
- `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`).
|
||||
@@ -61,7 +61,7 @@ detail not covered there:
|
||||
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
|
||||
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
|
||||
- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`.
|
||||
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.**
|
||||
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free.
|
||||
- `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively.
|
||||
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys.
|
||||
- `item_read` — the ONE place a `MediaItem*` is read for its canonical GUID string (`itemGuid`) and for the durable `P_LANENAME` of the fixed lane it sits on (`itemLaneName`); extracted from previously-duplicated `itemGuid`/`itemLaneName` pairs in `view.cpp` and `bank_panel.cpp` — the item-read analog of `track_guid`'s single `MediaTrack*`→GUID-key formatter. Callers must already know the track is fixed-lane (`I_FREEMODE==2`) before calling `itemLaneName`; the pure `isOnManualLane` predicate handles the non-fixed-lane case separately.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared
|
||||
// backend helpers (makeUniqueTag / stampCaptureSample).
|
||||
// backend helpers (makeUniqueTag / captureNameFor / collapseCapturedFileToMono /
|
||||
// stampCaptureSample).
|
||||
//
|
||||
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
|
||||
// the one TU that defines the API pointers; here they are extern.
|
||||
@@ -31,7 +32,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
|
||||
#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" // frameCountFor — the exact-bounds number
|
||||
@@ -227,13 +228,54 @@ CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
||||
return composeCaptureName(in);
|
||||
}
|
||||
|
||||
bool collapseCapturedFileToMono(const std::string& absolutePath) {
|
||||
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
|
||||
if (bytes.empty()) return false;
|
||||
|
||||
const MonoCollapse collapse = collapseToMono(bytes);
|
||||
if (!collapse.collapsed) return false;
|
||||
|
||||
// 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
|
||||
// this step existed), so a mid-write failure here must not land a truncated file that
|
||||
// stampCaptureSample then hashes as a false CaptureStatus::Ok. rename() replaces the
|
||||
// destination in one step, so the original bytes are never destroyed until the
|
||||
// replacement is known-complete; a failed write or rename leaves the original file
|
||||
// untouched and self-cleans the temp rather than littering it.
|
||||
const std::string tempPath = absolutePath + ".moncollapse.tmp";
|
||||
{
|
||||
std::ofstream out(tempPath, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return false;
|
||||
out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
|
||||
static_cast<std::streamsize>(collapse.bytes.size()));
|
||||
const bool wroteOk = static_cast<bool>(out);
|
||||
out.close();
|
||||
if (!wroteOk) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(tempPath, ec);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 0 (unknown, the same
|
||||
// sentinel bank_model already uses for a pre-field entry) rather than the
|
||||
// request's value — the request always asks for 2, so echoing it would claim a
|
||||
// measurement that never happened for the unparseable-file case below. The
|
||||
// produced FILE overrides it below whenever it parses.
|
||||
s.trackGuids = req.trackGuids;
|
||||
s.channelCount = req.channelCount;
|
||||
s.channelCount = 0;
|
||||
|
||||
// PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0
|
||||
// (honest "unknown") rather than a bogus literal.
|
||||
@@ -264,6 +306,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +519,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.
|
||||
const bool collapsedToMono = 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
|
||||
@@ -499,7 +553,8 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
result.message = "Captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath;
|
||||
paths.relativePath +
|
||||
(collapsedToMono ? " (collapsed to mono)" : "");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
// The shared capture seam: CaptureRequest/CaptureResult (types both backends
|
||||
// speak), OfflineRenderBackend, and the makeUniqueTag/stampCaptureSample helpers.
|
||||
// speak), OfflineRenderBackend, and the helpers both backends share (naming, the
|
||||
// mono collapse, the Sample stamp).
|
||||
// Realtime's async begin/tick/abort surface lives in capture_realtime_shell.h.
|
||||
//
|
||||
// REAPER-free on purpose (bank_model only) so callers can depend on the seam
|
||||
@@ -54,6 +55,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;
|
||||
|
||||
@@ -118,9 +122,19 @@ std::string makeUniqueTag(const std::string& prefix);
|
||||
CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
||||
int ordinal, const std::string& fallback);
|
||||
|
||||
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
|
||||
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
|
||||
// from `rateProj`), captureTempo, the capture-start time signature
|
||||
// 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.
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
// fallback for a file that cannot be parsed — never the request's value, which is
|
||||
// always 2 and was never actually measured), 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
|
||||
// contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp.
|
||||
|
||||
@@ -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.
|
||||
const bool collapsedToMono = 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.label();
|
||||
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);
|
||||
@@ -219,7 +225,8 @@ 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;
|
||||
paths.relativePath +
|
||||
(collapsedToMono ? " (collapsed to mono)" : "");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user