Q-W3: main.cpp → pointers+entry+dispatch via 4 capture hoists; one pure wav_codec RIFF owner; ICaptureBackend deleted; capture_realtime rename + finalize split; shared stampCaptureSample; makeUniqueTag gains monotonic counter (fixes same-second batch collisions). 60/60 green.

This commit is contained in:
2026-07-29 10:56:11 -04:00
parent d7d7f7e084
commit 09f7173db2
29 changed files with 2972 additions and 2426 deletions
+3 -109
View File
@@ -2,119 +2,13 @@
#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring> // std::memcmp
#include <filesystem>
#include <vector>
namespace reasampler::capture {
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
// Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
std::uint64_t h = kOffsetBasis;
for (std::size_t i = 0; i < len; ++i) {
h ^= static_cast<std::uint64_t>(data[i]);
h *= kPrime;
}
// Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
// Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
// through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
// Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
// If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
// to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
//
// The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
// FNV state instead of recording geometry — no second parser, same logic.
// FNV-1a 64-bit constants (same as hashBytes).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
auto tagEq = [&](std::size_t off, const char* tag) -> bool {
return off + 4 <= bytes.size() &&
std::memcmp(bytes.data() + off, tag, 4) == 0;
};
auto readU32LE = [&](std::size_t off) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[off]) |
(static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[off + 3]) << 24);
};
bool isWav = bytes.size() >= 12 &&
tagEq(0, "RIFF") &&
tagEq(8, "WAVE");
if (isWav) {
// Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
std::uint64_t h = kOffsetBasis;
auto feedByte = [&](std::uint8_t b) {
h ^= static_cast<std::uint64_t>(b);
h *= kPrime;
};
bool haveFmt = false;
bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(pos + 4);
if (tagEq(pos, "fmt ")) {
// Feed the entire fmt body (all fields, including format tag, channels,
// sample rate, bits-per-sample — everything that defines the audio format).
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveFmt = true;
}
} else if (tagEq(pos, "data")) {
// Feed the entire PCM payload.
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveData = true;
}
}
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
// Advance past this chunk's body, honoring RIFF even-byte padding.
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // RIFF pad byte
if (advance > bytes.size() - bodyOffset) break; // overrun guard
pos = bodyOffset + advance;
}
if (haveFmt && haveData) {
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
// Falls through to whole-file fallback if chunks were missing/malformed.
}
// Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
// per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
return hashBytes(bytes.data(), bytes.size());
}
// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec
// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the
// layout parse so hashing and decoding cannot desynchronize.
std::string normalizeSlashes(const std::string& path) {
std::string out = path;
+3 -31
View File
@@ -34,37 +34,9 @@ struct BankPaths {
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// 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) in place of the raw
// hashBytes call.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent)
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge.
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
// -> empty out. Pure string transform (does not consult the filesystem).
@@ -1,7 +1,8 @@
// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header.
// NO REAPER types; unit-tested by tests/test_realtime_record.cpp.
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp.
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
#include "core/capture/realtime_record.h"
#include "core/capture/capture_realtime.h"
namespace reasampler::capture {
@@ -1,9 +1,12 @@
#pragma once
// realtime_record — the REAPER-free logic behind the realtime-record backend (M8).
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8).
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp /
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.)
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The realtime backend (capture.cpp)
// drives the transport, the temp track, the send routing, and the file move —
// vendor/ includes. Standard library only. The realtime shell drives the
// transport, the temp track, the send routing, and the file move —
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
// pieces are split out here and unit-tested outside the DAW:
//
+333
View File
@@ -0,0 +1,333 @@
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor.
//
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the
// content hash both walk with it, so their view of the container cannot drift.
#include "core/capture/wav_codec.h"
#include <cstdio> // std::snprintf (hash hex render)
#include <cstring> // std::memcpy, std::memcmp
namespace reasampler::capture {
namespace {
// Little-endian readers. Bounds are checked by the caller before each read; these
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint32_t>(b[off]) |
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
(static_cast<std::uint32_t>(b[off + 3]) << 24);
}
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
}
// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION).
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
// FNV-1a 64-bit constants (http://www.isthe.com/chongo/tech/comp/fnv/).
constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
std::string fnvHex(std::uint64_t h) {
// 16-digit lowercase hex (zero-padded) for a fixed-length string.
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
return std::string(buf);
}
// --- The ONE RIFF chunk traversal --------------------------------------------
//
// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`.
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk
// whose declared size lies past the end is still REPORTED (callers decide how to
// treat it) but its body must not be read.
struct WavChunkView {
std::size_t headerOffset = 0;
std::size_t bodyOffset = 0;
std::uint32_t bodySize = 0;
bool bodyInBounds = false;
};
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
// padding. Returns false when no further chunk header fits. If the padded advance
// would overrun the buffer, the chunk is still reported (return true) and `pos` is
// parked past the end so the NEXT call returns false — exactly the process-then-
// break shape the pre-consolidation walkers shared.
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
WavChunkView& out) {
if (pos + 8 > bytes.size()) return false;
out.headerOffset = pos;
out.bodyOffset = pos + 8;
out.bodySize = readU32LE(bytes, pos + 4);
out.bodyInBounds = (out.bodyOffset + out.bodySize <= bytes.size());
std::size_t advance = out.bodySize;
if (advance & 1u) ++advance; // RIFF pad byte
if (advance > bytes.size() - out.bodyOffset) {
pos = bytes.size(); // overrun -> this is the last reported chunk
} else {
pos = out.bodyOffset + advance;
}
return true;
}
bool isRiffWave(const std::vector<std::uint8_t>& bytes) {
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
return bytes.size() >= 12 && tagEquals(bytes, 0, "RIFF") &&
tagEquals(bytes, 8, "WAVE");
}
} // namespace
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
WavLayout out;
if (!isRiffWave(bytes)) return out;
bool haveFmt = false;
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
std::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A
// malformed/truncated file is "invalid", never an OOB read.
std::size_t pos = 12;
WavChunkView c;
while (nextWavChunk(bytes, pos, c)) {
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
// fmt body: at least 16 bytes (PCM/float common fields).
if (c.bodyOffset + 16 > bytes.size() || c.bodySize < 16) return out;
fmtTag = readU16LE(bytes, c.bodyOffset + 0);
channels = readU16LE(bytes, c.bodyOffset + 2);
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) {
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
}
}
haveFmt = true;
} else if (tagEquals(bytes, c.headerOffset, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
// Reject if it runs past the buffer (truncated / lying header).
if (!c.bodyInBounds) return out;
if (!haveFmt) return out; // data before fmt — not a WAV we parse
// Plain IEEE-float tag (0x0003): accept as-is.
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
// float and must be rejected to prevent mis-decoding as float.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat);
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
out.valid = true;
out.channelCount = channels;
out.sampleRate = sampleRate;
out.dataByteOffset = c.bodyOffset;
out.dataByteLength = c.bodySize;
out.riffSizeFieldOffset = 4;
out.dataSizeFieldOffset = c.headerOffset + 4; // the `data` size field (LE uint32)
return out;
}
}
return out; // no data chunk found -> invalid
}
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount) {
std::vector<AudioSample> out;
if (!layout.valid) return out;
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t totalFrames = layout.frameCount();
if (startFrame >= totalFrames) return out;
// Clamp the requested span to the frames that actually exist.
const std::size_t avail = totalFrames - startFrame;
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
if (frames == 0) return out;
const std::size_t firstByte =
layout.dataByteOffset + startFrame * bytesPerFrame;
out.resize(frames * layout.channelCount);
// memcpy each float (LE on target hosts — see header's byte-order note).
for (std::size_t i = 0; i < out.size(); ++i) {
float f = 0.0f;
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
out[i] = f;
}
return out;
}
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
WavTruncatePlan plan;
if (!layout.valid) return plan;
const std::size_t totalFrames = layout.frameCount();
if (keptFrames > totalFrames) return plan; // never grow
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
plan.valid = true;
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
return plan;
}
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
}
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<double>& interleaved) {
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
const std::uint32_t riffSize =
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
std::vector<std::uint8_t> out;
out.reserve(44u + dataBytesCount);
auto putU16 = [&](std::uint16_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
};
auto putU32 = [&](std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
};
auto putTag = [&](const char* t) {
for (int i = 0; i < 4; ++i)
out.push_back(static_cast<std::uint8_t>(t[i]));
};
auto putF32 = [&](float f) {
std::uint8_t tmp[4];
std::memcpy(tmp, &f, 4);
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
};
// RIFF header
putTag("RIFF");
putU32(riffSize);
putTag("WAVE");
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
putTag("fmt ");
putU32(16u); // chunk body size
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
putU16(static_cast<std::uint16_t>(nch));
putU32(rate);
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
putU16(32u); // bitsPerSample
// data chunk
putTag("data");
putU32(static_cast<std::uint32_t>(dataBytesCount));
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
putF32(static_cast<float>(interleaved[i]));
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;
for (std::size_t i = 0; i < len; ++i) {
h ^= static_cast<std::uint64_t>(data[i]);
h *= kFnvPrime;
}
return fnvHex(h);
}
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
// Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt `
// body and `data` body through FNV-1a, prefixed with the domain-separation tag
// byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED,
// etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and
// data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized
// file still gets a hash.
if (isRiffWave(bytes)) {
std::uint64_t h = kFnvOffsetBasis;
auto feedByte = [&](std::uint8_t b) {
h ^= static_cast<std::uint64_t>(b);
h *= kFnvPrime;
};
bool haveFmt = false;
bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12;
WavChunkView c;
while (nextWavChunk(bytes, pos, c)) {
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
// Feed the entire fmt body (all fields, including format tag, channels,
// sample rate, bits-per-sample — everything that defines the audio format).
if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]);
haveFmt = true;
}
} else if (tagEquals(bytes, c.headerOffset, "data")) {
// Feed the entire PCM payload.
if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]);
haveData = true;
}
}
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
}
if (haveFmt && haveData) return fnvHex(h);
// Falls through to whole-file fallback if chunks were missing/malformed.
}
// Fallback: not a parseable RIFF/WAVE — hash the whole file (identical to
// hashBytes(data, size); no prefix tag).
return hashBytes(bytes.data(), bytes.size());
}
} // namespace reasampler::capture
+172
View File
@@ -0,0 +1,172 @@
#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 <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#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<std::size_t>(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<std::uint8_t>& 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<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& 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<std::uint8_t>& 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<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<double>& 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<std::uint8_t>& bytes);
} // namespace reasampler::capture
-160
View File
@@ -1,160 +0,0 @@
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
#include "core/capture/wav_trim.h"
#include <cstring> // std::memcpy, std::memcmp
namespace reasampler::capture {
namespace {
// Little-endian readers. Bounds are checked by the caller before each read; these
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint32_t>(b[off]) |
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
(static_cast<std::uint32_t>(b[off + 3]) << 24);
}
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
}
// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION).
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
} // namespace
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
WavLayout out;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
if (bytes.size() < 12) return out;
if (!tagEquals(bytes, 0, "RIFF")) return out;
if (!tagEquals(bytes, 8, "WAVE")) return out;
bool haveFmt = false;
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
std::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size),
// body padded to an even byte count (RIFF word alignment). Stop cleanly if a
// header would run past the buffer — a malformed/truncated file is "invalid",
// never an OOB read.
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(bytes, pos + 4);
if (tagEquals(bytes, pos, "fmt ")) {
// fmt body: at least 16 bytes (PCM/float common fields).
if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out;
fmtTag = readU16LE(bytes, bodyOffset + 0);
channels = readU16LE(bytes, bodyOffset + 2);
sampleRate = readU32LE(bytes, bodyOffset + 4);
bitsPerSample = readU16LE(bytes, bodyOffset + 14);
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) {
if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24);
}
}
haveFmt = true;
} else if (tagEquals(bytes, pos, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
// Reject if it runs past the buffer (truncated / lying header).
if (bodyOffset + bodySize > bytes.size()) return out;
if (!haveFmt) return out; // data before fmt — not a WAV we parse
// Plain IEEE-float tag (0x0003): accept as-is.
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
// float and must be rejected to prevent mis-decoding as float.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat);
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
out.valid = true;
out.channelCount = channels;
out.sampleRate = sampleRate;
out.dataByteOffset = bodyOffset;
out.dataByteLength = bodySize;
out.riffSizeFieldOffset = 4;
out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32)
return out;
}
// Advance past this chunk's body, honoring RIFF even-byte padding. Guard the
// additions against size_t overflow (a hostile bodySize near SIZE_MAX).
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // pad byte
if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop
pos = bodyOffset + advance;
}
return out; // no data chunk found -> invalid
}
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount) {
std::vector<AudioSample> out;
if (!layout.valid) return out;
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t totalFrames = layout.frameCount();
if (startFrame >= totalFrames) return out;
// Clamp the requested span to the frames that actually exist.
const std::size_t avail = totalFrames - startFrame;
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
if (frames == 0) return out;
const std::size_t firstByte =
layout.dataByteOffset + startFrame * bytesPerFrame;
out.resize(frames * layout.channelCount);
// memcpy each float (LE on target hosts — see header's byte-order note).
for (std::size_t i = 0; i < out.size(); ++i) {
float f = 0.0f;
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
out[i] = f;
}
return out;
}
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
WavTruncatePlan plan;
if (!layout.valid) return plan;
const std::size_t totalFrames = layout.frameCount();
if (keptFrames > totalFrames) return plan; // never grow
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
plan.valid = true;
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
return plan;
}
} // namespace reasampler::capture
+11 -99
View File
@@ -1,103 +1,15 @@
#pragma once
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
// wav_trim — TRANSITIONAL forwarding header (Q-W3, audit §4e WAV/RIFF consolidation).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
// The one pure owner of the WAV/RIFF byte format is now core/capture/wav_codec.{h,cpp}
// (chunk walker + layout parse + float32 build + size-field patch + content hash).
// Everything this header used to declare (WavLayout / parseWavLayout /
// extractFloatFrames / WavTruncatePlan / planWavTruncate) lives there, same
// namespace (reasampler::capture), same signatures — this include is a pure alias.
//
// WHY THIS 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 (capture_realtime.cpp) does only the file I/O: read the bytes, call the
// pure parse, run the decay scan, call the pure plan, 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). This 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.
// Kept ONLY so the TUs a parallel wave owns (sample_map.h and the VST editor/
// processor god-TUs, Q-W2v) compile untouched — editing them here would collide
// with that wave's in-flight split. Retire this header (and point its includers at
// wav_codec.h) once Q-W2v lands.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::capture {
using audio::AudioSample;
// 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<std::size_t>(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<std::uint8_t>& 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<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount);
// 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, then
// truncate the file to newFileByteLength.
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
} // namespace reasampler::capture
#include "core/capture/wav_codec.h"