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
+4 -3
View File
@@ -19,8 +19,9 @@ Per-module detail — what each file owns, its invariants — lives in the twent
paths anywhere in the index. paths anywhere in the index.
- **Material:** must handle full-mix/stem bounces, chops/one-shots, and - **Material:** must handle full-mix/stem bounces, chops/one-shots, and
single-cycle/wavetable grabs equally. That means exact sample-accurate bounds, single-cycle/wavetable grabs equally. That means exact sample-accurate bounds,
explicit tail control, channel-count preservation, and loop/zero-crossing explicit tail control, correct channel handling (the exact-bounds channel rule
handling all matter from day one. under Precision invariants), and loop/zero-crossing handling all matter from day
one.
## One-time submodule setup ## One-time submodule setup
@@ -206,7 +207,7 @@ Plan-style docs live under `docs/`:
- **Null test:** a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per `docs/product/provenance.md` — manual verification only.) - **Null test:** a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per `docs/product/provenance.md` — manual verification only.)
- **Bit-identical repeats:** identical offline capture requests produce identical files. - **Bit-identical repeats:** identical offline capture requests produce identical files.
- **Non-destructive:** capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored. - **Non-destructive:** capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored.
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; channel count preserved (no silent stereo fold). - **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; **no lossy channel fold** — summing or averaging differing channels is forbidden. The one permitted collapse is lossless: a new capture whose channels are bit-identical per frame (float bit patterns, never an epsilon) lands as a 1-channel file, with `Sample::channelCount` and the file's `fmt` written together so the two can never disagree. Frame count, sample rate and bit depth are untouched by it. Never retroactive — existing entries and files are never rewritten — and ingest is excluded, because an imported file is the user's bytes, not our capture. The superseded wording ("channel count preserved") was already untrue in the other direction: a mono source renders at `RENDER_CHANNELS = 2`. `[verify — DAW]` "lossless" here is a file-bytes property; whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a dual-mono 2-channel item (pan law, mono spread) — the null test's actual playback-chain property — is unconfirmed.
- **Relative paths only** in the persisted `BankIndex`. - **Relative paths only** in the persisted `BankIndex`.
- **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal. - **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
+5 -1
View File
@@ -2661,7 +2661,11 @@ LOSSY fold; this collapse is lossless by predicate) but contradicted in text. Th
amendment: channel count is preserved except that bit-identical channels may collapse amendment: channel count is preserved except that bit-identical channels may collapse
losslessly to mono; a lossy fold remains forbidden. Noted in the amendment: the old text losslessly to mono; a lossy fold remains forbidden. Noted in the amendment: the old text
was already untrue in the other direction — a mono source renders at `RENDER_CHANNELS=2` was already untrue in the other direction — a mono source renders at `RENDER_CHANNELS=2`
today (`capture_orchestrator.cpp:227`, hardcoded and never measured). today (`capture_orchestrator.cpp:227`, hardcoded and never measured). `[verify — DAW]`
"lossless" is proven at the file-bytes level; it is not the same claim as the null
test's playback-chain property (whether REAPER sums a 1-channel item on a stereo track
at the same unity gain as a dual-mono 2-channel item) — see the acceptance criteria's
own `[verify — DAW]` on that bullet below.
**Surface boundary — owns:** `core/capture/wav_codec` (the pure bit-identity predicate + **Surface boundary — owns:** `core/capture/wav_codec` (the pure bit-identity predicate +
collapse plan, with `wav_codec` unit tests), `shell/capture/capture.cpp` (the post-render collapse plan, with `wav_codec` unit tests), `shell/capture/capture.cpp` (the post-render
+39
View File
@@ -605,3 +605,42 @@ select/move the neighbour, or capture at track scope instead.
**Done looks like.** Nothing to do — recorded so a future reviewer does not read the **Done looks like.** Nothing to do — recorded so a future reviewer does not read the
non-isolation as an oversight and re-propose closing it against the recipe's stated non-isolation as an oversight and re-propose closing it against the recipe's stated
tracks-and-range-only shape. tracks-and-range-only shape.
## Resample-bake landings don't apply the lossless mono collapse to a dual-mono render
**Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono`
/ `core/capture/wav_codec::collapseToMono`) ships for every extension capture path —
offline, realtime, batch, recapture — but not for `bake_land.cpp`'s `landOne`, the
resample bake's landing function. A dead-center instrument render (the common case
that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate
collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need.
**Not deferred for the reason once given.** `landOne` reads the staged file into `bytes`
once (`bake_land.cpp:101`), parses its layout (`:105`), hashes it (`:126`), derives the
channel count twice (`:131`, `:178`), and writes it (`:165`) — all from that same one
buffer, so collapsing `bytes` right after the layout parse would keep the hash, the
channel count, and the written file consistent by construction; there is no ordering
hazard here to defer around.
**The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface
(Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing
a mutation there now would cross tracks mid-remediation for no urgent gain — the mono
propagation this item would add is a size win, not a correctness one.
**A mono capture already propagates through the bake for free**, so this item is scoped
to the dual-mono-*render* case only: `runBake` / `instrument_bake.cpp` already renders
however many channels the dialed sound has, and `bake_render.cpp:38` reads
`sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed
sound already bakes to a mono file today, with no change needed.
**Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on the staged
`bytes` in `landOne` right after the layout parse (`:105`) and before the hash (`:126`),
matching the offline/realtime insertion point (post-parse, pre-identity-read).
**Priority / risk.** Low — a size optimization on an already-correct path, not a
precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger)
stereo file today.
**Done looks like.** A dead-center instrument bake lands as a 1-channel file with
`Sample::channelCount` matching, the same way an offline dead-center capture does; a
true-stereo bake is byte-identical to today's output.
+17 -1
View File
@@ -45,7 +45,7 @@ Detail specific to these pure modules:
## 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`). - `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. - `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. - `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 - `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the 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. 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 - `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants `render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
in separate files by design (panel-facing default/step vs. runaway-guard cap) 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; 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) { std::string hashBytes(const std::uint8_t* data, std::size_t len) {
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
std::uint64_t h = kFnvOffsetBasis; 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, std::size_t frameCount,
const std::vector<double>& interleaved); 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) ----------------------------------------- // --- Content identity (dedup hashes) -----------------------------------------
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase // Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
+5
View File
@@ -88,6 +88,11 @@ struct Sample {
double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry
// Channels in the file this entry names — equal to its `fmt ` count by
// construction on every path that measures it, which is what makes the
// instrument's mono/stereo-toggle default agree with the audio (the waveform
// lane count reads the decoded file directly, not this field). 0 = unknown —
// a pre-field entry, or a capture whose file could not be parsed to measure it.
int channelCount = 0; int channelCount = 0;
int sampleRate = 0; int sampleRate = 0;
+2 -2
View File
@@ -51,7 +51,7 @@ detail not covered there:
## Modules ## 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`. - `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_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`). - `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. - `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_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`. - `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. - `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. - `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. - `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.
+61 -6
View File
@@ -1,5 +1,6 @@
// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared // 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 // Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
// the one TU that defines the API pointers; here they are extern. // the one TU that defines the API pointers; here they are extern.
@@ -31,7 +32,7 @@
#include <vector> #include <vector>
#include "core/capture/capture_paths.h" #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/util/file_bytes.h"
#include "core/capture/render_settings.h" #include "core/capture/render_settings.h"
#include "core/capture/render_window.h" // frameCountFor — the exact-bounds number #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); 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, void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj, ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath) { const std::string& absolutePath) {
// Track GUIDs + channel count: echoed from the request (the caller resolved // Track GUIDs echoed from the request (the caller resolved the selection; the
// the selection; the backends stay source-agnostic). // 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.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 // PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0
// (honest "unknown") rather than a bogus literal. // (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); const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
if (!fileBytes.empty()) { if (!fileBytes.empty()) {
s.contentHash = hashWavContent(fileBytes); 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. // Record the request's own bounds (exact) rather than re-measuring the file.
Sample s; Sample s;
// Same uniqueTag that named the file — calling makeUniqueTag() again could // Same uniqueTag that named the file — calling makeUniqueTag() again could
@@ -499,7 +553,8 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
result.message = "Captured [" + result.message = "Captured [" +
std::to_string(request.startSeconds) + "s, " + std::to_string(request.startSeconds) + "s, " +
std::to_string(request.endSeconds) + "s] -> " + std::to_string(request.endSeconds) + "s] -> " +
paths.relativePath; paths.relativePath +
(collapsedToMono ? " (collapsed to mono)" : "");
return result; return result;
} }
+18 -4
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
// The shared capture seam: CaptureRequest/CaptureResult (types both backends // 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. // 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 // 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. // 0 sampleRate => follow project rate.
int sampleRate = 0; 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; int channelCount = 2;
WavBitDepth bitDepth = WavBitDepth::Float32; WavBitDepth bitDepth = WavBitDepth::Float32;
@@ -118,9 +122,19 @@ std::string makeUniqueTag(const std::string& prefix);
CaptureName captureNameFor(const std::vector<std::string>& sourceNames, CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
int ordinal, const std::string& fallback); int ordinal, const std::string& fallback);
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount // Rewrites a just-captured WAV in place as a 1-channel file when its channels are
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE // bit-identical (the pure `collapseToMono` decides). Every other file is left
// from `rateProj`), captureTempo, the capture-start time signature // 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 // (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
// active project, realtime pins the record's own project), the WAV-aware // active project, realtime pins the record's own project), the WAV-aware
// contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp. // contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp.
@@ -184,6 +184,10 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
request.endSeconds); 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). // Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
RecordedCapture cap; RecordedCapture cap;
cap.relativePath = paths.relativePath; cap.relativePath = paths.relativePath;
@@ -194,7 +198,9 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
cap.wetDry = request.wetDry; cap.wetDry = request.wetDry;
cap.displayName = request.label(); cap.displayName = request.label();
cap.trackGuids = request.trackGuids; 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.status = CaptureStatus::Ok;
result.sample = sampleFromRecordedCapture(cap); result.sample = sampleFromRecordedCapture(cap);
@@ -219,7 +225,8 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
std::to_string(request.startSeconds) + "s, " + std::to_string(request.startSeconds) + "s, " +
std::to_string(request.endSeconds) + "s] (recorded " + std::to_string(request.endSeconds) + "s] (recorded " +
std::to_string(result.sample.lengthSeconds) + "s) -> " + std::to_string(result.sample.lengthSeconds) + "s) -> " +
paths.relativePath; paths.relativePath +
(collapsedToMono ? " (collapsed to mono)" : "");
return result; return result;
} }
+3 -2
View File
@@ -148,8 +148,9 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// no-play — no crash, no retry loop. // no-play — no crash, no retry loop.
if (const SelectedSample* sel = findRef(refs, selId)) { if (const SelectedSample* sel = findRef(refs, selId)) {
// Auto-default: channelModeFor computes the mode from the loaded capture's channel // 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). // count — 1 for an ingested mono file or a capture whose channels came out
// An unknown count (0) or explicit user choice keeps the mode. // 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_); std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_, channelMode_ = channelModeFor(sel->channelCount, channelMode_,
+18
View File
@@ -603,8 +603,26 @@ static void testSeamFieldsAdditiveInvariant() {
CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields
} }
// A collapsed capture is a 1-channel entry, and the JSON is the only thing carrying
// that count across a project reload — the instrument's mono/stereo default reads it.
static void testMonoChannelCountRoundTrip() {
BankModel idx;
Sample s = fullSample("mono");
s.channelCount = 1;
CHECK(idx.add(s) == AddResult::Added);
const std::string json = idx.serialize();
CHECK(json.find("\"channelCount\":1") != std::string::npos);
auto back = BankModel::deserialize(json);
CHECK(back.has_value());
CHECK(back && back->query("id-mono") &&
back->query("id-mono")->channelCount == 1);
}
int main() { int main() {
testFullFieldRoundTrip(); testFullFieldRoundTrip();
testMonoChannelCountRoundTrip();
testSerializeGoldenLiteral(); testSerializeGoldenLiteral();
testDedupByHash(); testDedupByHash();
testTierFilterAndMove(); testTierFilterAndMove();
+193 -1
View File
@@ -11,10 +11,14 @@
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent // buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
// determinism, metadata-skip, fallback, and domain separation; a golden hash // determinism, metadata-skip, fallback, and domain separation; a golden hash
// literal pinning exact hex output for a fixed input (guards persisted // literal pinning exact hex output for a fixed input (guards persisted
// contentHash values against a silent feed-sequence drift). // contentHash values against a silent feed-sequence drift); and the lossless mono
// collapse (bit-identical N-channel fold, the one-sample-differs and signed-zero
// declines, already-mono, zero/single-frame, an odd padded leading chunk, and the
// content-hash consequence).
#include "../src/core/capture/wav_codec.h" #include "../src/core/capture/wav_codec.h"
#include <cmath>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@@ -47,6 +51,16 @@ static void putFloat(std::vector<std::uint8_t>& b, float f) {
std::memcpy(tmp, &f, 4); std::memcpy(tmp, &f, 4);
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
} }
static float floatFromBits(std::uint32_t bits) {
float f;
std::memcpy(&f, &bits, 4);
return f;
}
static std::uint32_t bitsFromFloat(float f) {
std::uint32_t bits;
std::memcpy(&bits, &f, 4);
return bits;
}
// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding // A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding
// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an // `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an
@@ -593,6 +607,174 @@ static void testGoldenHashLiterals() {
CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44"); CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44");
} }
// --- Lossless mono collapse --------------------------------------------------
// Every channel carries frame f's value; the collapse must keep those values verbatim
// in one channel and leave frame count / rate / bit depth alone.
static void testCollapseBitIdenticalStereo() {
auto wav = buildFloatWav(2, 48000, 6,
[](std::size_t f, std::uint16_t) {
return 0.25f * static_cast<float>(f) - 0.5f;
});
const MonoCollapse c = collapseToMono(wav);
CHECK(c.collapsed);
const WavLayout L = parseWavLayout(c.bytes);
CHECK(L.valid); // valid implies float32: the parser rejects anything else
CHECK(L.channelCount == 1);
CHECK(L.sampleRate == 48000);
CHECK(L.frameCount() == 6);
const auto pcm = extractFloatFrames(c.bytes, L, 0, 6);
CHECK(pcm.size() == 6);
for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
}
static void testCollapseDeclinesOnOneDifferingSample() {
// Identical everywhere except frame 4's right channel, by the smallest step the
// format can express near 1.0.
auto wav = buildFloatWav(2, 48000, 8,
[](std::size_t f, std::uint16_t ch) {
float v = 1.0f + static_cast<float>(f);
if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
return v;
});
CHECK(!collapseToMono(wav).collapsed);
CHECK(collapseToMono(wav).bytes.empty());
}
// An already-mono file must come back untouched — a second capture pass over a
// collapsed file must not rebuild (and so must not re-hash) it.
static void testCollapseDeclinesOnAlreadyMono() {
auto wav = buildFloatWav(1, 44100, 4,
[](std::size_t f, std::uint16_t) {
return static_cast<float>(f);
});
CHECK(!collapseToMono(wav).collapsed);
}
// N-channel generalization: all-identical collapses to ONE channel, never a partial
// fold (4 -> 2). Unreachable from today's capture paths, which always render 2.
static void testCollapseFourChannels() {
auto same = buildFloatWav(4, 48000, 5,
[](std::size_t f, std::uint16_t) {
return -0.125f * static_cast<float>(f);
});
const MonoCollapse c = collapseToMono(same);
CHECK(c.collapsed);
const WavLayout L = parseWavLayout(c.bytes);
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 5);
auto oneDiffers = buildFloatWav(4, 48000, 5,
[](std::size_t f, std::uint16_t ch) {
float v = -0.125f * static_cast<float>(f);
if (f == 2 && ch == 3) v += 0.5f;
return v;
});
CHECK(!collapseToMono(oneDiffers).collapsed);
}
static void testCollapseZeroAndSingleFrame() {
// No frame of evidence that the channels agree -> decline rather than rebuild.
auto empty = buildFloatWav(2, 48000, 0,
[](std::size_t, std::uint16_t) { return 0.0f; });
CHECK(parseWavLayout(empty).valid && parseWavLayout(empty).frameCount() == 0);
CHECK(!collapseToMono(empty).collapsed);
auto one = buildFloatWav(2, 48000, 1,
[](std::size_t, std::uint16_t) { return 0.75f; });
const MonoCollapse c = collapseToMono(one);
CHECK(c.collapsed);
const WavLayout L = parseWavLayout(c.bytes);
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
CHECK(pcm.size() == 1 && pcm[0] == 0.75f);
}
// The predicate is over BIT PATTERNS: -0.0f == +0.0f compares equal as floats but is
// a different value on disk, so folding it would not be lossless.
static void testCollapseSignedZeroIsNotIdentical() {
auto wav = buildFloatWav(2, 48000, 3,
[](std::size_t, std::uint16_t ch) {
return ch == 0 ? 0.0f : -0.0f;
});
CHECK(!collapseToMono(wav).collapsed);
}
// A leading odd-sized chunk exercises the walk's RIFF pad byte; the rebuilt file is
// canonical, so that chunk does not survive.
static void testCollapseThroughOddPaddedLeadingChunk() {
std::vector<std::uint8_t> chunks;
putTag(chunks, "LIST");
putU32(chunks, 5); // odd body -> one pad byte
for (int i = 0; i < 5; ++i) chunks.push_back(0x41);
chunks.push_back(0); // the pad
putTag(chunks, "fmt ");
putU32(chunks, 16);
putU16(chunks, 3);
putU16(chunks, 2);
putU32(chunks, 48000);
putU32(chunks, 48000u * 2u * 4u);
putU16(chunks, 8);
putU16(chunks, 32);
putTag(chunks, "data");
putU32(chunks, 3u * 2u * 4u);
for (std::size_t f = 0; f < 3; ++f)
for (int ch = 0; ch < 2; ++ch) putFloat(chunks, 0.5f * static_cast<float>(f));
std::vector<std::uint8_t> wav;
putTag(wav, "RIFF");
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
putTag(wav, "WAVE");
wav.insert(wav.end(), chunks.begin(), chunks.end());
const MonoCollapse c = collapseToMono(wav);
CHECK(c.collapsed);
const WavLayout L = parseWavLayout(c.bytes);
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 3);
// Canonical rebuild: byte-for-byte what buildFloat32Wav produces for the same PCM.
CHECK(c.bytes == buildFloat32Wav(1, 48000, 3, {0.0, 0.5, 1.0}));
}
static void testCollapseDeclinesOnUnparseableBytes() {
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
CHECK(!collapseToMono(junk).collapsed);
CHECK(!collapseToMono(std::vector<std::uint8_t>{}).collapsed);
}
// Stated consequence, pinned: the collapse rewrites both the `fmt ` body and the
// `data` payload, so a collapsed capture no longer shares content identity with the
// stereo file it came from and will not dedup against one already in the bank.
static void testCollapseChangesContentHash() {
auto wav = buildFloatWav(2, 48000, 4,
[](std::size_t f, std::uint16_t) {
return static_cast<float>(f);
});
const MonoCollapse c = collapseToMono(wav);
CHECK(c.collapsed);
CHECK(hashWavContent(c.bytes) != hashWavContent(wav));
}
// The float->double->float rebuild's stated hole is a SIGNALING NaN (double promotion
// quiets it); a QUIET NaN is not that hole. Both channels carry the identical
// quiet-NaN bit pattern, so the predicate collapses; the rebuilt mono channel must
// carry that exact bit pattern back, not merely "some NaN".
static void testCollapsePreservesQuietNaNBitPattern() {
constexpr std::uint32_t kQuietNaNBits = 0x7FC12345u; // exponent all-ones, mantissa MSB set
auto wav = buildFloatWav(2, 48000, 1,
[kQuietNaNBits](std::size_t, std::uint16_t) {
return floatFromBits(kQuietNaNBits);
});
const MonoCollapse c = collapseToMono(wav);
CHECK(c.collapsed);
const WavLayout L = parseWavLayout(c.bytes);
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
CHECK(pcm.size() == 1);
if (!pcm.empty()) CHECK(bitsFromFloat(pcm[0]) == kQuietNaNBits);
}
int main() { int main() {
testParseCanonicalStereo(); testParseCanonicalStereo();
testParseMonoAndLeadingChunk(); testParseMonoAndLeadingChunk();
@@ -618,6 +800,16 @@ int main() {
testHashWavContentDomainSeparationFromWholeFile(); testHashWavContentDomainSeparationFromWholeFile();
testHashMatchesBuildOutput(); testHashMatchesBuildOutput();
testGoldenHashLiterals(); testGoldenHashLiterals();
testCollapseBitIdenticalStereo();
testCollapseDeclinesOnOneDifferingSample();
testCollapseDeclinesOnAlreadyMono();
testCollapseFourChannels();
testCollapseZeroAndSingleFrame();
testCollapseSignedZeroIsNotIdentical();
testCollapseThroughOddPaddedLeadingChunk();
testCollapseDeclinesOnUnparseableBytes();
testCollapseChangesContentHash();
testCollapsePreservesQuietNaNBitPattern();
if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail); else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);