334 lines
14 KiB
C++
334 lines
14 KiB
C++
// 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
|