From 4fa3c1dd1546ae7872ec56b5d0f8ccb48b7d39a1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 21:46:55 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=CE=A8-W2-T2:=20collapse=20a=20capture=20wh?= =?UTF-8?q?ose=20channels=20are=20bit-identical=20to=20one=20lossless=20mo?= =?UTF-8?q?no=20channel,=20index=20value=20measured=20off=20the=20landed?= =?UTF-8?q?=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- src/core/capture/CLAUDE.md | 10 +- src/core/capture/wav_codec.cpp | 38 ++++ src/core/capture/wav_codec.h | 26 +++ src/core/model/bank_model.h | 3 + src/shell/capture/CLAUDE.md | 2 +- src/shell/capture/capture.cpp | 32 +++- src/shell/capture/capture.h | 15 +- .../capture/capture_realtime_finalize.cpp | 8 +- src/shell/instrument/processor_reload.cpp | 5 +- tests/test_bank_model.cpp | 18 ++ tests/test_wav_codec.cpp | 164 +++++++++++++++++- 12 files changed, 312 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5562a54..2587e6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,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.) - **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. -- **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`. - **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. diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index c740414..222502f 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -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. @@ -81,6 +81,14 @@ 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. - `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) diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp index a08268e..042b95d 100644 --- a/src/core/capture/wav_codec.cpp +++ b/src/core/capture/wav_codec.cpp @@ -257,6 +257,44 @@ std::vector buildFloat32Wav(int nch, std::uint32_t rate, return out; } +MonoCollapse collapseToMono(const std::vector& 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 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 (double represents every float), + // so channel 0 reaches the rebuilt file unaltered. + std::vector mono(frames); + for (std::size_t f = 0; f < frames; ++f) + mono[f] = static_cast(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; diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h index 615f2e8..9bb52ba 100644 --- a/src/core/capture/wav_codec.h +++ b/src/core/capture/wav_codec.h @@ -90,6 +90,32 @@ std::vector buildFloat32Wav(int nch, std::uint32_t rate, std::size_t frameCount, const std::vector& 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 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. +MonoCollapse collapseToMono(const std::vector& bytes); + // --- Content identity (dedup hashes) ----------------------------------------- // Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase diff --git a/src/core/model/bank_model.h b/src/core/model/bank_model.h index c9d391e..ff4f669 100644 --- a/src/core/model/bank_model.h +++ b/src/core/model/bank_model.h @@ -88,6 +88,9 @@ struct Sample { 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, which is what makes the instrument's mono/stereo read-out and + // its waveform lane count agree with the audio. 0 = unknown (pre-field entry). int channelCount = 0; int sampleRate = 0; diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 2cf1287..fd59d1d 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -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`). diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index ca79649..3118f54 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -201,11 +201,27 @@ std::string makeUniqueTag(const std::string& prefix) { std::to_string(++counter); } +void collapseCapturedFileToMono(const std::string& absolutePath) { + const std::vector 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(collapse.bytes.data()), + static_cast(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 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(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 diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index 4ef62a1..49ac2c8 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -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 diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp index 7552ab0..c6d3b9e 100644 --- a/src/shell/capture/capture_realtime_finalize.cpp +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -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); diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 51380f4..eb5b769 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -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 cm(channelModeMutex_); channelMode_ = channelModeFor(sel->channelCount, channelMode_, diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 299aef0..fb1d1fe 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -573,8 +573,26 @@ static void testSeamFieldsAdditiveInvariant() { 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() { testFullFieldRoundTrip(); + testMonoChannelCountRoundTrip(); testSerializeGoldenLiteral(); testDedupByHash(); testTierFilterAndMove(); diff --git a/tests/test_wav_codec.cpp b/tests/test_wav_codec.cpp index b768e71..2dbdfab 100644 --- a/tests/test_wav_codec.cpp +++ b/tests/test_wav_codec.cpp @@ -11,10 +11,14 @@ // buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent // determinism, metadata-skip, fallback, and domain separation; a golden hash // 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 #include #include #include @@ -593,6 +597,155 @@ static void testGoldenHashLiterals() { 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(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(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(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(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(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(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 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(f)); + + std::vector wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(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 junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'}; + CHECK(!collapseToMono(junk).collapsed); + CHECK(!collapseToMono(std::vector{}).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(f); + }); + const MonoCollapse c = collapseToMono(wav); + CHECK(c.collapsed); + CHECK(hashWavContent(c.bytes) != hashWavContent(wav)); +} + int main() { testParseCanonicalStereo(); testParseMonoAndLeadingChunk(); @@ -618,6 +771,15 @@ int main() { testHashWavContentDomainSeparationFromWholeFile(); testHashMatchesBuildOutput(); testGoldenHashLiterals(); + testCollapseBitIdenticalStereo(); + testCollapseDeclinesOnOneDifferingSample(); + testCollapseDeclinesOnAlreadyMono(); + testCollapseFourChannels(); + testCollapseZeroAndSingleFrame(); + testCollapseSignedZeroIsNotIdentical(); + testCollapseThroughOddPaddedLeadingChunk(); + testCollapseDeclinesOnUnparseableBytes(); + testCollapseChangesContentHash(); if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail); From 8b191e3379b0c6c78c96da8c376adc5bb133158f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 22:08:39 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=CE=A8-W2-T2=20remediation:=20atomic=20temp?= =?UTF-8?q?+rename=20collapse=20write,=20honest=20unknown-channel=20fallba?= =?UTF-8?q?ck,=20[verify=20=E2=80=94=20DAW]=20markers,=20corrected+filed?= =?UTF-8?q?=20bake-collapse=20deferral,=20observable=20collapse=20message,?= =?UTF-8?q?=20quiet-NaN=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 7 ++- docs/PLAN.md | 6 +- docs/TODO.md | 39 +++++++++++++ src/core/capture/CLAUDE.md | 8 +++ src/core/capture/wav_codec.cpp | 9 ++- src/core/capture/wav_codec.h | 3 +- src/core/model/bank_model.h | 6 +- src/shell/capture/CLAUDE.md | 2 +- src/shell/capture/capture.cpp | 58 ++++++++++++++----- src/shell/capture/capture.h | 9 ++- .../capture/capture_realtime_finalize.cpp | 5 +- tests/test_wav_codec.cpp | 30 ++++++++++ 12 files changed, 151 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2587e6c..0529fc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,9 @@ Per-module detail — what each file owns, its invariants — lives in the twent paths anywhere in the index. - **Material:** must handle full-mix/stem bounces, chops/one-shots, and single-cycle/wavetable grabs equally. That means exact sample-accurate bounds, - explicit tail control, channel-count preservation, and loop/zero-crossing - handling all matter from day one. + explicit tail control, correct channel handling (the exact-bounds channel rule + under Precision invariants), and loop/zero-crossing handling all matter from day + one. ## 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.) - **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. -- **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`. +- **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`. - **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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 4afdf1b..a62c9d6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 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` -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 + collapse plan, with `wav_codec` unit tests), `shell/capture/capture.cpp` (the post-render diff --git a/docs/TODO.md b/docs/TODO.md index eafb197..14b66b9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -577,3 +577,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 non-isolation as an oversight and re-propose closing it against the recipe's stated 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. diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 222502f..bb8fb1e 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -89,6 +89,14 @@ Detail specific to these pure modules: 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) diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp index 042b95d..eb4b1ff 100644 --- a/src/core/capture/wav_codec.cpp +++ b/src/core/capture/wav_codec.cpp @@ -284,8 +284,13 @@ MonoCollapse collapseToMono(const std::vector& bytes) { } } - // float -> double -> float round-trips exactly (double represents every float), - // so channel 0 reaches the rebuilt file unaltered. + // 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 mono(frames); for (std::size_t f = 0; f < frames; ++f) mono[f] = static_cast(pcm[f * stride]); diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h index 9bb52ba..9fad071 100644 --- a/src/core/capture/wav_codec.h +++ b/src/core/capture/wav_codec.h @@ -113,7 +113,8 @@ struct MonoCollapse { // 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. +// 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& bytes); // --- Content identity (dedup hashes) ----------------------------------------- diff --git a/src/core/model/bank_model.h b/src/core/model/bank_model.h index ff4f669..597b5e7 100644 --- a/src/core/model/bank_model.h +++ b/src/core/model/bank_model.h @@ -89,8 +89,10 @@ struct Sample { 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, which is what makes the instrument's mono/stereo read-out and - // its waveform lane count agree with the audio. 0 = unknown (pre-field entry). + // 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 sampleRate = 0; diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index fd59d1d..1c562ae 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -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. diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 3118f54..f388ae0 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -1,5 +1,5 @@ // REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared -// backend helpers (makeUniqueTag / stampCaptureSample). +// backend helpers (makeUniqueTag / 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 +31,7 @@ #include #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 @@ -201,29 +201,54 @@ std::string makeUniqueTag(const std::string& prefix) { std::to_string(++counter); } -void collapseCapturedFileToMono(const std::string& absolutePath) { +bool collapseCapturedFileToMono(const std::string& absolutePath) { const std::vector bytes = util::readFileBytes(absolutePath); - if (bytes.empty()) return; + if (bytes.empty()) return false; const MonoCollapse collapse = collapseToMono(bytes); - if (!collapse.collapsed) return; + if (!collapse.collapsed) return false; - // 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(collapse.bytes.data()), - static_cast(collapse.bytes.size())); + // 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(collapse.bytes.data()), + static_cast(collapse.bytes.size())); + const bool wroteOk = static_cast(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 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. + // 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. @@ -473,7 +498,7 @@ 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. - collapseCapturedFileToMono(expectedPath); + const bool collapsedToMono = collapseCapturedFileToMono(expectedPath); // Record the request's own bounds (exact) rather than re-measuring the file. Sample s; @@ -501,7 +526,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; } diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index 49ac2c8..b051d5a 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -107,11 +107,14 @@ std::string makeUniqueTag(const std::string& prefix); // 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); +// 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`; the request -// value only as the fallback for a file that cannot be parsed), resolved +// 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 diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp index c6d3b9e..53d9158 100644 --- a/src/shell/capture/capture_realtime_finalize.cpp +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -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. - collapseCapturedFileToMono(destPath); + const bool collapsedToMono = collapseCapturedFileToMono(destPath); // Pure recorded-capture -> Sample mapping (identity, bounds echo, tier). RecordedCapture cap; @@ -225,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; } diff --git a/tests/test_wav_codec.cpp b/tests/test_wav_codec.cpp index 2dbdfab..c3d8204 100644 --- a/tests/test_wav_codec.cpp +++ b/tests/test_wav_codec.cpp @@ -51,6 +51,16 @@ static void putFloat(std::vector& b, float f) { std::memcpy(tmp, &f, 4); 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 // `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an @@ -746,6 +756,25 @@ static void testCollapseChangesContentHash() { 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() { testParseCanonicalStereo(); testParseMonoAndLeadingChunk(); @@ -780,6 +809,7 @@ int main() { testCollapseThroughOddPaddedLeadingChunk(); testCollapseDeclinesOnUnparseableBytes(); testCollapseChangesContentHash(); + testCollapsePreservesQuietNaNBitPattern(); if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);