diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp index c0b5d8a..8fd36e9 100644 --- a/src/core/capture/wav_codec.cpp +++ b/src/core/capture/wav_codec.cpp @@ -6,6 +6,7 @@ #include // std::snprintf (hash hex render) #include // std::memcpy, std::memcmp +#include // std::move namespace reasampler::capture { @@ -300,6 +301,23 @@ MonoCollapse collapseToMono(const std::vector& bytes) { return out; } +CollapsedWav applyMonoCollapse(std::vector bytes) { + CollapsedWav out; + MonoCollapse collapse = collapseToMono(bytes); + if (collapse.collapsed) { + const WavLayout rebuilt = parseWavLayout(collapse.bytes); + if (rebuilt.valid) { + out.bytes = std::move(collapse.bytes); + out.layout = rebuilt; + out.collapsed = true; + return out; + } + } + out.bytes = std::move(bytes); + out.layout = parseWavLayout(out.bytes); + return out; +} + std::string monoCollapseSuffix(MonoCollapseOutcome outcome) { switch (outcome) { case MonoCollapseOutcome::Declined: return {}; diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h index 88ab15d..4e92b47 100644 --- a/src/core/capture/wav_codec.h +++ b/src/core/capture/wav_codec.h @@ -117,6 +117,22 @@ struct MonoCollapse { // including the bext/source-position consequence beyond hashing. MonoCollapse collapseToMono(const std::vector& bytes); +// A buffer after the collapse has had its say, PAIRED with the parse of the bytes +// actually returned — so a caller that hashes `bytes`, reads a channel count off +// `layout` and then writes `bytes` cannot describe one buffer while writing another. +struct CollapsedWav { + std::vector bytes; // the rebuilt 1-channel WAV, or the input verbatim + WavLayout layout; // the parse OF `bytes` + bool collapsed = false; +}; + +// `collapseToMono` over a whole buffer, for a caller that goes on to hash and measure +// the result rather than rewrite a file (`shell/capture`'s collapseCapturedFileToMono is +// the file-side path over the same predicate). Takes the buffer by value: a decline hands +// those same bytes straight back. A rebuild that does not parse back is discarded rather +// than returned, so an invalid `layout` can only ever mean the INPUT was not a usable WAV. +CollapsedWav applyMonoCollapse(std::vector bytes); + // How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own // "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did // not land. The capture is intact and correctly measured in every case — only the report diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index c953d69..064b826 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -69,7 +69,7 @@ detail not covered there: - `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`). - `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely). - `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. -- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked. +- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. It takes the lossless mono collapse on the staged BUFFER (`wav_codec::applyMonoCollapse`, the same predicate the two backends' file-side `collapseCapturedFileToMono` runs) before the hash and before the channel-count read, so the hash, the entry and the written file all come from one buffer — a dead-center render lands 1-channel like any other dead-center capture. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked. - `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. - `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). diff --git a/src/shell/capture/bake_landing.cpp b/src/shell/capture/bake_landing.cpp index a4ce618..2cf9e69 100644 --- a/src/shell/capture/bake_landing.cpp +++ b/src/shell/capture/bake_landing.cpp @@ -13,7 +13,7 @@ #include #include "core/capture/capture_paths.h" // deriveBankPaths -#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent +#include "core/capture/wav_codec.h" // applyMonoCollapse / hashWavContent #include "core/model/bank_book.h" #include "core/model/bank_model.h" #include "core/model/resample_name.h" // the iteration-chain display name @@ -85,7 +85,12 @@ PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& pr "the staged render was unreadable", request.generation); return prep; } - const WavLayout layout = parseWavLayout(prep.bytes); + // Before the hash, so nothing measures a buffer it won't write. + // `staged.collapsed` goes unread: a rebuild that fails to reparse reverts to the + // staged bytes inside applyMonoCollapse itself, so there is nothing left here to react to. + CollapsedWav staged = applyMonoCollapse(std::move(prep.bytes)); + prep.bytes = std::move(staged.bytes); + const WavLayout layout = staged.layout; if (!layout.valid || layout.frameCount() == 0) { prep.settled = refuseBake(BakeStatus::StagedMissing, "the staged render is not a usable WAV", request.generation); diff --git a/src/shell/capture/bake_landing.h b/src/shell/capture/bake_landing.h index e7d9f1d..bcc2660 100644 --- a/src/shell/capture/bake_landing.h +++ b/src/shell/capture/bake_landing.h @@ -1,8 +1,9 @@ #pragma once -// bake_landing — landing ONE bake request into the loaded project's bank: read and hash the -// staged WAV, resolve replace-vs-add, write the file, index it, seed its lineage. The scan -// pass that finds requests across the open tabs and answers them is `bake_land`; this is -// what it calls per request, and it neither reads nor writes an ext-state key. +// bake_landing — landing ONE bake request into the loaded project's bank: read the staged +// WAV, collapse it losslessly to mono when it is dual-mono, hash it, resolve replace-vs-add, +// write the file, index it, seed its lineage. The scan pass that finds requests across the +// open tabs and answers them is `bake_land`; this is what it calls per request, and it +// neither reads nor writes an ext-state key. #include #include diff --git a/tests/test_wav_codec.cpp b/tests/test_wav_codec.cpp index ec39a03..777f1b9 100644 --- a/tests/test_wav_codec.cpp +++ b/tests/test_wav_codec.cpp @@ -14,7 +14,8 @@ // 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). +// content-hash consequence) plus its buffer-side wrapper applyMonoCollapse (the +// bytes/layout pairing, the byte-identical decline, one-ULP, and double-apply). #include "../src/core/capture/wav_codec.h" @@ -795,6 +796,119 @@ static void testCollapseOutcomeSuffixesAreDistinctStrings() { CHECK(collapsed.find("failed") == std::string::npos); } +// --- applyMonoCollapse: the buffer-side collapse a bake landing takes --------- + +// A dead-center instrument render is dual-mono, and must land 1-channel: the returned +// layout says one channel, and it is the parse OF the returned bytes, so the caller's +// channelCount, its hash and the file it writes cannot come from different buffers. +static void testApplyCollapseDualMonoLandsOneChannel() { + auto wav = buildFloatWav(2, 48000, 6, + [](std::size_t f, std::uint16_t) { + return 0.25f * static_cast(f) - 0.5f; + }); + const CollapsedWav staged = applyMonoCollapse(wav); + CHECK(staged.collapsed); + CHECK(staged.layout.valid); + CHECK(staged.layout.channelCount == 1); + + const WavLayout reparsed = parseWavLayout(staged.bytes); + CHECK(reparsed.valid); + CHECK(reparsed.channelCount == staged.layout.channelCount); + CHECK(reparsed.sampleRate == staged.layout.sampleRate); + CHECK(reparsed.frameCount() == staged.layout.frameCount()); + CHECK(reparsed.dataByteOffset == staged.layout.dataByteOffset); + CHECK(reparsed.dataByteLength == staged.layout.dataByteLength); + + const auto pcm = extractFloatFrames(staged.bytes, staged.layout, 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); +} + +// A true-stereo bake must land exactly the bytes it staged — this is the regression the +// collapse must not cause, so it is asserted on the bytes themselves, not on the verdict. +static void testApplyCollapseTrueStereoIsByteIdentical() { + auto wav = buildFloatWav(2, 48000, 5, + [](std::size_t f, std::uint16_t ch) { + return ch == 0 ? static_cast(f) + : -static_cast(f); + }); + const CollapsedWav staged = applyMonoCollapse(wav); + CHECK(!staged.collapsed); + CHECK(staged.bytes == wav); + CHECK(staged.layout.channelCount == 2); + CHECK(staged.layout.frameCount() == 5); + // The dedup key a declined bake writes is the one it would have written before the + // collapse existed. + CHECK(hashWavContent(staged.bytes) == hashWavContent(wav)); +} + +// One float ULP apart in ONE sample is a difference, not an epsilon: the buffer path +// must decline it exactly as the predicate does, and hand the bytes back untouched. +static void testApplyCollapseDeclinesOnOneUlpDifference() { + 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; + }); + const CollapsedWav staged = applyMonoCollapse(wav); + CHECK(!staged.collapsed); + CHECK(staged.bytes == wav); + CHECK(staged.layout.channelCount == 2); +} + +// A sound that was already mono comes back untouched, and a collapsed buffer fed back +// through does not collapse a second time (the rebuild would otherwise re-hash). +static void testApplyCollapseAlreadyMonoIsUntouched() { + auto mono = buildFloatWav(1, 44100, 4, + [](std::size_t f, std::uint16_t) { + return static_cast(f); + }); + const CollapsedWav staged = applyMonoCollapse(mono); + CHECK(!staged.collapsed); + CHECK(staged.bytes == mono); + CHECK(staged.layout.channelCount == 1); + + auto dual = buildFloatWav(2, 44100, 4, + [](std::size_t f, std::uint16_t) { + return static_cast(f); + }); + const CollapsedWav once = applyMonoCollapse(dual); + CHECK(once.collapsed); + const CollapsedWav twice = applyMonoCollapse(once.bytes); + CHECK(!twice.collapsed); + CHECK(twice.bytes == once.bytes); +} + +// The collapse is permitted only because it is lossless: frame count, sample rate and +// bit depth survive it, and only the interleave stride changes. +static void testApplyCollapsePreservesFramesRateAndDepth() { + auto wav = buildFloatWav(2, 44100, 7, + [](std::size_t f, std::uint16_t) { + return 0.5f - 0.125f * static_cast(f); + }); + const WavLayout before = parseWavLayout(wav); + const CollapsedWav staged = applyMonoCollapse(wav); + CHECK(staged.collapsed); + CHECK(staged.layout.frameCount() == before.frameCount()); + CHECK(staged.layout.sampleRate == before.sampleRate); + // `valid` implies 32-bit float (the parser accepts nothing else), and 4 bytes per + // frame at one channel is that depth spelled out in the data chunk's own length. + CHECK(staged.layout.valid); + CHECK(staged.layout.dataByteLength == before.frameCount() * 4u); +} + +// Bytes that never parsed keep `collapsed` false AND `layout.valid` false — the pair a +// caller refuses on, and the reason an invalid layout can only mean a bad INPUT. +static void testApplyCollapseUnparseableInputIsReportedInvalid() { + std::vector junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'}; + const CollapsedWav staged = applyMonoCollapse(junk); + CHECK(!staged.collapsed); + CHECK(!staged.layout.valid); + CHECK(staged.bytes == junk); +} + int main() { testParseCanonicalStereo(); testParseMonoAndLeadingChunk(); @@ -831,6 +945,12 @@ int main() { testCollapseChangesContentHash(); testCollapsePreservesQuietNaNBitPattern(); testCollapseOutcomeSuffixesAreDistinctStrings(); + testApplyCollapseDualMonoLandsOneChannel(); + testApplyCollapseTrueStereoIsByteIdentical(); + testApplyCollapseDeclinesOnOneUlpDifference(); + testApplyCollapseAlreadyMonoIsUntouched(); + testApplyCollapsePreservesFramesRateAndDepth(); + testApplyCollapseUnparseableInputIsReportedInvalid(); if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);