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:
2026-08-01 22:23:06 -04:00
14 changed files with 444 additions and 22 deletions
+17 -1
View File
@@ -45,7 +45,7 @@ Detail specific to these pure modules:
## Modules
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + the lossless mono collapse + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`).
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition``NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
@@ -82,6 +82,22 @@ Detail specific to these pure modules:
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
FX-bypass-around-render mechanism, never by a render bit.
- **The mono collapse changes a capture's content identity, by design.**
`hashWavContent` covers the `fmt ` body plus the `data` payload, and the collapse
rewrites both — so a collapsed capture does NOT hash-dedup against a stereo twin of
the same audio already in the bank. Accepted: the predicate is deterministic over
deterministic bytes, so repeats of the same request still dedup against each other,
which is what the bit-identical-repeats invariant actually asks for. Do not "fix"
this by hashing pre-collapse — that would make two entries with different audio
layouts share one identity.
- **The collapse's minimal rebuild also drops `bext`/iXML/LIST — a source-position
consequence, not only a hashing one.** REAPER's renderer writes a `bext` time
reference, and REAPER's own import paths can position an item at that BWF timestamp,
so a collapsed capture loses it while a declined (non-collapsed) capture from the same
action keeps it — two captures from one action behave differently on re-import.
`shell/capture/insert.cpp` is unaffected (it drives `SetEditCurPos` + `InsertMedia`
rather than reading BWF), so this is not a defect in the shipped insert path.
Accepted, not verified against a DAW re-import: `[verify — DAW]`.
- `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
in separate files by design (panel-facing default/step vs. runaway-guard cap)
+43
View File
@@ -257,6 +257,49 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
return out;
}
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
MonoCollapse out;
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid || layout.channelCount < 2) return out;
const std::size_t frames = layout.frameCount();
if (frames == 0) return out;
const std::size_t stride = layout.channelCount;
const std::vector<AudioSample> pcm = extractFloatFrames(bytes, layout, 0, frames);
if (pcm.size() != frames * stride) return out; // short read -> decline, never guess
// Bit patterns, not values: see the header. memcpy is the only defined float->bits
// read, and it compiles to a register move.
auto bitsOf = [](AudioSample s) {
std::uint32_t bits = 0;
std::memcpy(&bits, &s, 4u);
return bits;
};
for (std::size_t f = 0; f < frames; ++f) {
const std::uint32_t first = bitsOf(pcm[f * stride]);
for (std::size_t c = 1; c < stride; ++c) {
if (bitsOf(pcm[f * stride + c]) != first) return out;
}
}
// float -> double -> float round-trips exactly for every finite value and for
// +-0/+-infinity (double represents every float bit pattern in those classes), so
// channel 0 reaches the rebuilt file unaltered. The one hole: a signaling NaN is
// quieted by the float->double promotion, so an identical-bit sNaN pair could
// collapse to a different bit pattern than it started with. Not reachable from
// REAPER-rendered audio, but the bit-identical predicate above admits NaN inputs,
// so this rebuild is not exempt from the claim it makes.
std::vector<double> mono(frames);
for (std::size_t f = 0; f < frames; ++f)
mono[f] = static_cast<double>(pcm[f * stride]);
out.collapsed = true;
out.bytes = buildFloat32Wav(1, layout.sampleRate, frames, mono);
return out;
}
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;
+27
View File
@@ -90,6 +90,33 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<double>& interleaved);
// --- Lossless mono collapse ---------------------------------------------------
// The outcome of the bit-identical mono collapse. `collapsed == false` means the
// caller must leave the source file exactly as it is — it writes nothing.
struct MonoCollapse {
bool collapsed = false;
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV; empty unless collapsed
};
// Collapses a multi-channel float32 WAV to one channel when EVERY channel of EVERY
// frame carries the identical float BIT PATTERN. Bit equality, never an epsilon and
// never `==` on floats: +0.0/-0.0 and two NaNs with differing payloads are NOT
// identical and are never folded. Frame count, sample rate and bit depth are
// preserved — only the interleave stride changes — so the collapse cannot lose
// information, and a lossy downmix (summing differing channels) is not something
// this can express.
//
// Declines for: bytes that do not parse; a file already at one channel; a zero-frame
// file (no frame of evidence to act on); any differing channel pair.
//
// The rebuild is a canonical minimal WAV, so non-audio chunks (a renderer's `bext`
// timestamp, iXML, LIST) do not survive it. That much hashWavContent already skips —
// but the collapse rewrites the `fmt ` body and the `data` payload too, which moves
// the file's content identity; see this directory's CLAUDE.md for what that costs,
// including the bext/source-position consequence beyond hashing.
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
// --- Content identity (dedup hashes) -----------------------------------------
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase