#pragma once // wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: // T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 // build + size-field patch + the WAV-aware content hash, in one tested module. // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. Builds and unit-tests without REAPER. // // Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte // padding, size fields) was minted at four sites: wav_trim's layout parse, // capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and // capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte // handling) would desynchronize hashing from decoding — the dedup-by-hash and // null-test invariants both sit on this. Now every walker/builder/patcher is here, // on ONE chunk-traversal implementation. // // WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime // backend records a generous tail window, then trims the trailing decay by // truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is // not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk // size and the `data` sub-chunk size) must be patched to the kept byte count, or // the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking, // format verification, and the size-field patch offsets — is exactly the fiddly, // easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER // shell does only the file I/O: read the bytes, call the pure parse, run the decay // scan, call the pure plan, patch + write the truncated bytes. // // FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV // (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project // record format, which the manual procedure sets to WAV/32-bit-float). The parser // therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt ` // chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE // with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything // else (a different depth, a non-WAV, a compressed source) is reported invalid and // the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a // file it does not understand. This is deliberately conservative. #include #include #include #include #include "core/audio/peaks.h" // AudioSample (float) namespace reasampler::capture { using audio::AudioSample; // --- Layout parse ------------------------------------------------------------ // The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the // bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field // is meaningful only when valid. struct WavLayout { bool valid = false; std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) // The `data` chunk: byte offset of its first PCM byte within the file, and its // declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). std::size_t dataByteOffset = 0; std::size_t dataByteLength = 0; // Byte offset of the two little-endian uint32 size fields the truncate patch // rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk // size (the 4 bytes immediately before dataByteOffset). std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file std::size_t dataSizeFieldOffset = 0; std::size_t frameCount() const { const std::size_t bytesPerFrame = static_cast(channelCount) * 4u; return bytesPerFrame ? dataByteLength / bytesPerFrame : 0; } }; // Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything // that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, // or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only // locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). WavLayout parseWavLayout(const std::vector& bytes); // Copies `frameCount` interleaved float frames starting at `startFrame` out of the // WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). // Clamps to the frames the buffer actually holds — never reads past `data`. Returns // empty for an invalid layout or an out-of-range start. The floats are read // little-endian via std::memcpy (no aliasing UB); on a big-endian host they would // need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux // on x86/ARM-LE) is little-endian and REAPER writes LE WAV. std::vector extractFloatFrames(const std::vector& bytes, const WavLayout& layout, std::size_t startFrame, std::size_t frameCount); // --- Truncate plan + size-field patch --------------------------------------- // The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte // length and the two size-field values to patch. `valid` is false if the layout is // invalid or keptFrames exceeds the file's frames (never GROW a file — the caller // clamps beforehand; this guards it too). struct WavTruncatePlan { bool valid = false; std::size_t newFileByteLength = 0; // truncate the file to exactly this length std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32) std::uint32_t newDataSize = 0; // kept PCM byte length std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32) std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes // the 8-byte "RIFF"+size prefix) }; // Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. // keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + // total. The shell applies it: patch the two size fields in the byte buffer // (patchU32LE), then truncate the file to newFileByteLength. WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); // Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size // fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() // (the plan's offsets came from a valid parse of the same buffer). void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v); // --- Float32 WAV build ------------------------------------------------------- // Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved // double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, // 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, // `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is // narrowed to float by cast — the bank contract is 32-bit float (see FORMAT // ASSUMPTION above); the reduction is intentional. The output round-trips through // parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical // source through REAPER's PCM_source, then writes the bank copy with this. std::vector buildFloat32Wav(int nch, std::uint32_t rate, std::size_t frameCount, const std::vector& interleaved); // --- Content identity (dedup hashes) ----------------------------------------- // Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` // and returns it as a 16-character lowercase hex string. Designed to fill // Sample::contentHash so the confirm-on-last-reference guardrail // (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this // file" from "another bank holds the same file." An empty buffer returns the bare // FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty // files would share, but real WAV files are never empty). std::string hashBytes(const std::uint8_t* data, std::size_t len); // WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float // RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all // other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). // // WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a // `bext` chunk containing the origination date/time) even when the format config blob // requests no BWF metadata. Two renders of identical audio therefore differ in those // bytes, making whole-file hashes diverge and preventing dedup collapse. // // DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before // the fmt/data bytes are fed in, so a content hash can never equal a whole-file // hashBytes result for a different file of the same size. // // FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a // `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — // identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an // unrecognized or malformed file still gets a non-empty hash rather than silently // skipping dedup. // // Called by both capture commit paths (offline and realtime) and the ingest import // in place of the raw hashBytes call. Walks the container with the SAME chunk // traversal parseWavLayout uses, so hashing and decoding can never desynchronize. std::string hashWavContent(const std::vector& bytes); } // namespace reasampler::capture