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:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user