788 lines
35 KiB
C++
788 lines
35 KiB
C++
// Standalone tests for reasampler::wav_codec — no REAPER, no test framework.
|
|
// The ONE pure WAV/RIFF owner (Q-W3, audit §4e): builds synthetic 32-bit-float WAV
|
|
// byte buffers, asserts the parse geometry, the float extraction, the truncate-plan
|
|
// arithmetic + size-field patch, the float32 build round-trip, and the WAV-aware
|
|
// content hashes (moved here from capture_paths with the hash implementations).
|
|
//
|
|
// Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped;
|
|
// format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame
|
|
// extraction (whole / tail window / clamp / out-of-range); truncate plan (kept<all,
|
|
// no-op keep-all, kept==0, grow rejected) with exact size-field values; patchU32LE;
|
|
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
|
|
// determinism, metadata-skip, fallback, and domain separation; a golden hash
|
|
// literal pinning exact hex output for a fixed input (guards persisted
|
|
// contentHash values against a silent feed-sequence drift); and the lossless mono
|
|
// collapse (bit-identical N-channel fold, the one-sample-differs and signed-zero
|
|
// declines, already-mono, zero/single-frame, an odd padded leading chunk, and the
|
|
// content-hash consequence).
|
|
|
|
#include "../src/core/capture/wav_codec.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <vector>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::capture;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
// --- Synthetic WAV builder ---------------------------------------------------
|
|
|
|
static void putU16(std::vector<std::uint8_t>& b, std::uint16_t v) {
|
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
|
}
|
|
static void putU32(std::vector<std::uint8_t>& b, std::uint32_t v) {
|
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
|
}
|
|
static void putTag(std::vector<std::uint8_t>& b, const char* t) {
|
|
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
|
|
}
|
|
static void putFloat(std::vector<std::uint8_t>& b, float f) {
|
|
std::uint8_t tmp[4];
|
|
std::memcpy(tmp, &f, 4);
|
|
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
|
|
}
|
|
|
|
// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding
|
|
// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an
|
|
// unknown chunk before fmt to exercise the chunk walk. Samples: frame f, channel c
|
|
// = value(f,c).
|
|
template <typename Fn>
|
|
static std::vector<std::uint8_t> buildFloatWav(std::uint16_t channels,
|
|
std::uint32_t sampleRate,
|
|
std::size_t frames,
|
|
Fn value,
|
|
bool leadingJunk = false,
|
|
std::uint16_t fmtTag = 3,
|
|
std::uint16_t bits = 32) {
|
|
const std::uint32_t dataBytes =
|
|
static_cast<std::uint32_t>(frames * channels * (bits / 8));
|
|
|
|
std::vector<std::uint8_t> chunks; // everything after "WAVE"
|
|
if (leadingJunk) {
|
|
putTag(chunks, "LIST");
|
|
putU32(chunks, 4);
|
|
putTag(chunks, "INFO"); // 4-byte body, even -> no pad
|
|
}
|
|
// fmt chunk (16-byte body).
|
|
putTag(chunks, "fmt ");
|
|
putU32(chunks, 16);
|
|
putU16(chunks, fmtTag); // format tag
|
|
putU16(chunks, channels);
|
|
putU32(chunks, sampleRate);
|
|
const std::uint32_t byteRate = sampleRate * channels * (bits / 8);
|
|
putU32(chunks, byteRate);
|
|
putU16(chunks, static_cast<std::uint16_t>(channels * (bits / 8))); // block align
|
|
putU16(chunks, bits);
|
|
// data chunk.
|
|
putTag(chunks, "data");
|
|
putU32(chunks, dataBytes);
|
|
for (std::size_t f = 0; f < frames; ++f)
|
|
for (std::uint16_t c = 0; c < channels; ++c)
|
|
putFloat(chunks, value(f, c));
|
|
|
|
std::vector<std::uint8_t> wav;
|
|
putTag(wav, "RIFF");
|
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size())); // "WAVE" + chunks
|
|
putTag(wav, "WAVE");
|
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
|
return wav;
|
|
}
|
|
|
|
// Builds a WAVE_FORMAT_EXTENSIBLE (0xFFFE) WAV with a 40-byte fmt body.
|
|
// `subFormatTag` is the 2-byte leading tag embedded in the SubFormat GUID:
|
|
// 0x0003 = IEEE float, 0x0001 = PCM integer (and any other value to exercise rejection).
|
|
// bitsPerSample and the PCM data are always 32-bit float bytes regardless of subFormatTag
|
|
// (we're testing that the parser correctly rejects/accepts based on the GUID, not the data).
|
|
template <typename Fn>
|
|
static std::vector<std::uint8_t> buildExtensibleWav(std::uint16_t channels,
|
|
std::uint32_t sampleRate,
|
|
std::size_t frames,
|
|
Fn value,
|
|
std::uint16_t subFormatTag) {
|
|
const std::uint32_t dataBytes =
|
|
static_cast<std::uint32_t>(frames * channels * 4u);
|
|
|
|
// WAVEFORMATEXTENSIBLE fmt body (40 bytes):
|
|
// [0..1] wFormatTag = 0xFFFE
|
|
// [2..3] nChannels
|
|
// [4..7] nSamplesPerSec
|
|
// [8..11] nAvgBytesPerSec
|
|
// [12..13] nBlockAlign
|
|
// [14..15] wBitsPerSample = 32
|
|
// [16..17] cbSize = 22 (extension size beyond the 18-byte WAVEFORMATEX)
|
|
// [18..19] wValidBitsPerSample = 32
|
|
// [20..23] dwChannelMask = 0
|
|
// [24..39] SubFormat GUID: first 2 bytes = subFormatTag (LE), rest = standard
|
|
// KSDATAFORMAT_SUBTYPE base GUID {00000000-0000-0010-8000-00aa00389b71}
|
|
std::vector<std::uint8_t> fmt;
|
|
putU16(fmt, 0xFFFE); // wFormatTag
|
|
putU16(fmt, channels); // nChannels
|
|
putU32(fmt, sampleRate); // nSamplesPerSec
|
|
putU32(fmt, sampleRate * channels * 4u); // nAvgBytesPerSec
|
|
putU16(fmt, static_cast<std::uint16_t>(channels * 4)); // nBlockAlign
|
|
putU16(fmt, 32); // wBitsPerSample
|
|
putU16(fmt, 22); // cbSize
|
|
putU16(fmt, 32); // wValidBitsPerSample
|
|
putU32(fmt, 0); // dwChannelMask
|
|
// SubFormat GUID (16 bytes): [subFormatTag, 0x0000, 0x00, 0x00, 0x10, 0x00,
|
|
// 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71]
|
|
putU16(fmt, subFormatTag); // bytes [24..25]: the effective format tag
|
|
putU16(fmt, 0x0000); // bytes [26..27]
|
|
fmt.push_back(0x00); fmt.push_back(0x00); // bytes [28..29]
|
|
fmt.push_back(0x10); fmt.push_back(0x00); // bytes [30..31]
|
|
fmt.push_back(0x80); fmt.push_back(0x00); // bytes [32..33]
|
|
fmt.push_back(0x00); fmt.push_back(0xaa); // bytes [34..35]
|
|
fmt.push_back(0x00); fmt.push_back(0x38); // bytes [36..37]
|
|
fmt.push_back(0x9b); fmt.push_back(0x71); // bytes [38..39]
|
|
|
|
std::vector<std::uint8_t> chunks;
|
|
putTag(chunks, "fmt ");
|
|
putU32(chunks, static_cast<std::uint32_t>(fmt.size())); // 40
|
|
chunks.insert(chunks.end(), fmt.begin(), fmt.end());
|
|
putTag(chunks, "data");
|
|
putU32(chunks, dataBytes);
|
|
for (std::size_t f = 0; f < frames; ++f)
|
|
for (std::uint16_t c = 0; c < channels; ++c)
|
|
putFloat(chunks, value(f, c));
|
|
|
|
std::vector<std::uint8_t> wav;
|
|
putTag(wav, "RIFF");
|
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
|
putTag(wav, "WAVE");
|
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
|
return wav;
|
|
}
|
|
|
|
// --- Parse tests -------------------------------------------------------------
|
|
|
|
static void testParseCanonicalStereo() {
|
|
auto wav = buildFloatWav(2, 48000, 5,
|
|
[](std::size_t f, std::uint16_t c) {
|
|
return static_cast<float>(f) + 0.1f * c;
|
|
});
|
|
WavLayout L = parseWavLayout(wav);
|
|
CHECK(L.valid);
|
|
CHECK(L.channelCount == 2);
|
|
CHECK(L.sampleRate == 48000);
|
|
CHECK(L.dataByteLength == 5 * 2 * 4);
|
|
CHECK(L.frameCount() == 5);
|
|
// data body sits after RIFF(12) + fmt(8 header + 16 body) + data(8 header) = 44.
|
|
CHECK(L.dataByteOffset == 44);
|
|
CHECK(L.dataSizeFieldOffset == 40); // the 4 bytes before dataByteOffset
|
|
CHECK(L.riffSizeFieldOffset == 4);
|
|
}
|
|
|
|
static void testParseMonoAndLeadingChunk() {
|
|
// A leading LIST/INFO chunk before fmt must be skipped by the walk.
|
|
auto wav = buildFloatWav(1, 44100, 3,
|
|
[](std::size_t f, std::uint16_t) {
|
|
return static_cast<float>(f);
|
|
},
|
|
/*leadingJunk=*/true);
|
|
WavLayout L = parseWavLayout(wav);
|
|
CHECK(L.valid);
|
|
CHECK(L.channelCount == 1);
|
|
CHECK(L.frameCount() == 3);
|
|
// Data still parses correctly despite the leading chunk shifting its offset.
|
|
auto pcm = extractFloatFrames(wav, L, 0, 3);
|
|
CHECK(pcm.size() == 3);
|
|
CHECK(pcm[0] == 0.0f && pcm[1] == 1.0f && pcm[2] == 2.0f);
|
|
}
|
|
|
|
static void testParseRejectsNon32BitAndNonWav() {
|
|
// 16-bit PCM (tag 1, bits 16) -> rejected.
|
|
auto pcm16 = buildFloatWav(2, 48000, 4,
|
|
[](std::size_t, std::uint16_t) { return 0.0f; },
|
|
false, /*fmtTag=*/1, /*bits=*/16);
|
|
CHECK(!parseWavLayout(pcm16).valid);
|
|
|
|
// Not a RIFF file.
|
|
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
|
CHECK(!parseWavLayout(junk).valid);
|
|
|
|
// Too short to hold even the RIFF header.
|
|
std::vector<std::uint8_t> tiny = {'R','I','F','F'};
|
|
CHECK(!parseWavLayout(tiny).valid);
|
|
}
|
|
|
|
static void testParseRejectsLyingDataLength() {
|
|
// Build a valid WAV, then inflate the `data` size field so it claims more bytes
|
|
// than the buffer holds -> must be rejected (no OOB trust).
|
|
auto wav = buildFloatWav(2, 48000, 4,
|
|
[](std::size_t, std::uint16_t) { return 1.0f; });
|
|
WavLayout good = parseWavLayout(wav);
|
|
CHECK(good.valid);
|
|
// Overwrite the data size field with a huge value.
|
|
wav[good.dataSizeFieldOffset + 0] = 0xFF;
|
|
wav[good.dataSizeFieldOffset + 1] = 0xFF;
|
|
wav[good.dataSizeFieldOffset + 2] = 0xFF;
|
|
wav[good.dataSizeFieldOffset + 3] = 0x7F;
|
|
CHECK(!parseWavLayout(wav).valid);
|
|
}
|
|
|
|
// --- Extraction tests --------------------------------------------------------
|
|
|
|
static void testExtractTailWindow() {
|
|
// Stereo, 10 frames. Sample value encodes frame+channel so a mis-index is caught.
|
|
auto wav = buildFloatWav(2, 48000, 10,
|
|
[](std::size_t f, std::uint16_t c) {
|
|
return static_cast<float>(f) * 10.0f + c;
|
|
});
|
|
WavLayout L = parseWavLayout(wav);
|
|
CHECK(L.valid);
|
|
|
|
// The "tail region" the realtime trim scans: frames 6..9 (start at frame 6).
|
|
auto tail = extractFloatFrames(wav, L, 6, 100 /*clamps*/);
|
|
CHECK(tail.size() == 4 * 2); // frames 6,7,8,9, 2 channels each
|
|
CHECK(tail[0] == 60.0f && tail[1] == 61.0f); // frame 6: L=60,R=61
|
|
CHECK(tail[6] == 90.0f && tail[7] == 91.0f); // frame 9: L=90,R=91
|
|
|
|
// Out-of-range start -> empty.
|
|
CHECK(extractFloatFrames(wav, L, 10, 4).empty());
|
|
CHECK(extractFloatFrames(wav, L, 99, 4).empty());
|
|
}
|
|
|
|
// --- Truncate-plan tests -----------------------------------------------------
|
|
|
|
static void testTruncatePlanKeepFewer() {
|
|
auto wav = buildFloatWav(2, 48000, 10,
|
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
|
WavLayout L = parseWavLayout(wav);
|
|
CHECK(L.valid);
|
|
|
|
// Keep 4 of 10 frames.
|
|
WavTruncatePlan p = planWavTruncate(L, 4);
|
|
CHECK(p.valid);
|
|
const std::size_t bpf = 2 * 4; // channels * 4 bytes
|
|
CHECK(p.newDataSize == 4 * bpf); // 32 bytes of PCM kept
|
|
CHECK(p.newFileByteLength == L.dataByteOffset + 4 * bpf); // 44 + 32 = 76
|
|
CHECK(p.newRiffSize == p.newFileByteLength - 8);
|
|
CHECK(p.dataSizeFieldOffset == L.dataSizeFieldOffset);
|
|
CHECK(p.riffSizeFieldOffset == 4);
|
|
|
|
// Applying the plan yields a buffer that re-parses to exactly 4 frames.
|
|
std::vector<std::uint8_t> trimmed(wav.begin(),
|
|
wav.begin() + p.newFileByteLength);
|
|
// Patch the two size fields with the module's own patch primitive (what the
|
|
// shell does before truncating on disk).
|
|
patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
|
patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
|
|
|
WavLayout L2 = parseWavLayout(trimmed);
|
|
CHECK(L2.valid);
|
|
CHECK(L2.frameCount() == 4);
|
|
CHECK(L2.dataByteLength == 4 * bpf);
|
|
}
|
|
|
|
static void testTruncatePlanKeepAllIsNoOp() {
|
|
auto wav = buildFloatWav(1, 48000, 6,
|
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
|
WavLayout L = parseWavLayout(wav);
|
|
WavTruncatePlan p = planWavTruncate(L, 6); // keep all
|
|
CHECK(p.valid);
|
|
CHECK(p.newFileByteLength == wav.size()); // unchanged
|
|
CHECK(p.newDataSize == L.dataByteLength);
|
|
}
|
|
|
|
// --- Extensible format tests -------------------------------------------------
|
|
|
|
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0001 (PCM integer) and
|
|
// bitsPerSample==32 must be REJECTED — it is 32-bit integer, not 32-bit float.
|
|
static void testExtensiblePcmIntegerRejected() {
|
|
auto wav = buildExtensibleWav(2, 48000, 4,
|
|
[](std::size_t, std::uint16_t) { return 0.0f; },
|
|
/*subFormatTag=*/0x0001); // PCM integer
|
|
CHECK(!parseWavLayout(wav).valid);
|
|
}
|
|
|
|
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0003 (IEEE float) and
|
|
// bitsPerSample==32 must be ACCEPTED and parse + trim correctly.
|
|
static void testExtensibleFloatAccepted() {
|
|
auto wav = buildExtensibleWav(2, 48000, 5,
|
|
[](std::size_t f, std::uint16_t c) {
|
|
return static_cast<float>(f) + 0.1f * c;
|
|
},
|
|
/*subFormatTag=*/0x0003); // IEEE float
|
|
WavLayout L = parseWavLayout(wav);
|
|
CHECK(L.valid);
|
|
CHECK(L.channelCount == 2);
|
|
CHECK(L.sampleRate == 48000);
|
|
CHECK(L.frameCount() == 5);
|
|
|
|
// Frame extraction works correctly.
|
|
auto pcm = extractFloatFrames(wav, L, 0, 2);
|
|
CHECK(pcm.size() == 4);
|
|
CHECK(pcm[0] == 0.0f); // frame 0, channel 0
|
|
CHECK(pcm[1] == 0.1f); // frame 0, channel 1
|
|
|
|
// Truncate plan is valid and re-parses cleanly.
|
|
WavTruncatePlan p = planWavTruncate(L, 3);
|
|
CHECK(p.valid);
|
|
CHECK(p.newDataSize == 3 * 2 * 4u);
|
|
std::vector<std::uint8_t> trimmed(wav.begin(), wav.begin() + p.newFileByteLength);
|
|
patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
|
patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
|
WavLayout L2 = parseWavLayout(trimmed);
|
|
CHECK(L2.valid);
|
|
CHECK(L2.frameCount() == 3);
|
|
}
|
|
|
|
static void testTruncatePlanKeepZeroAndGrowRejected() {
|
|
auto wav = buildFloatWav(2, 48000, 5,
|
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
|
WavLayout L = parseWavLayout(wav);
|
|
|
|
WavTruncatePlan zero = planWavTruncate(L, 0);
|
|
CHECK(zero.valid);
|
|
CHECK(zero.newDataSize == 0);
|
|
CHECK(zero.newFileByteLength == L.dataByteOffset); // header only
|
|
|
|
// keptFrames > total -> refused (never grow a file).
|
|
CHECK(!planWavTruncate(L, 6).valid);
|
|
|
|
// Invalid layout -> invalid plan.
|
|
WavLayout bad;
|
|
CHECK(!planWavTruncate(bad, 0).valid);
|
|
}
|
|
|
|
// --- patchU32LE (the size-field patch primitive) ------------------------------
|
|
|
|
static void testPatchU32LEWritesLittleEndian() {
|
|
std::vector<std::uint8_t> buf(8, 0xEE);
|
|
patchU32LE(buf, 2, 0x0A0B0C0Du);
|
|
CHECK(buf[0] == 0xEE && buf[1] == 0xEE); // bytes outside the field untouched
|
|
CHECK(buf[2] == 0x0D && buf[3] == 0x0C && buf[4] == 0x0B && buf[5] == 0x0A);
|
|
CHECK(buf[6] == 0xEE && buf[7] == 0xEE);
|
|
}
|
|
|
|
// --- buildFloat32Wav (the one WAV writer, absorbed from ingest — T4-10) -------
|
|
|
|
static void testBuildFloat32WavGoldenHeaderAndRoundTrip() {
|
|
// 2 channels, 3 frames of known interleaved values.
|
|
const std::vector<double> pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125};
|
|
auto wav = buildFloat32Wav(2, 48000, 3, pcm);
|
|
|
|
// Golden container shape: 44-byte header + 6 samples * 4 bytes.
|
|
CHECK(wav.size() == 44u + 6u * 4u);
|
|
CHECK(std::memcmp(wav.data(), "RIFF", 4) == 0);
|
|
CHECK(std::memcmp(wav.data() + 8, "WAVE", 4) == 0);
|
|
CHECK(std::memcmp(wav.data() + 12, "fmt ", 4) == 0);
|
|
CHECK(std::memcmp(wav.data() + 36, "data", 4) == 0);
|
|
CHECK(wav[20] == 0x03 && wav[21] == 0x00); // WAVE_FORMAT_IEEE_FLOAT
|
|
CHECK(wav[34] == 32 && wav[35] == 0); // bitsPerSample = 32
|
|
|
|
// The build round-trips through the module's own parse + extraction, with the
|
|
// documented double->float narrowing.
|
|
WavLayout L = parseWavLayout(wav);
|
|
CHECK(L.valid);
|
|
CHECK(L.channelCount == 2);
|
|
CHECK(L.sampleRate == 48000);
|
|
CHECK(L.frameCount() == 3);
|
|
auto back = extractFloatFrames(wav, L, 0, 3);
|
|
CHECK(back.size() == 6);
|
|
for (std::size_t i = 0; i < back.size(); ++i)
|
|
CHECK(back[i] == static_cast<float>(pcm[i]));
|
|
}
|
|
|
|
static void testBuildFloat32WavShortInputRejectedByParse() {
|
|
// Fewer interleaved samples than frameCount*nch declares: the data chunk still
|
|
// declares the full length; the missing tail simply is not written. The build
|
|
// caller (ingest) always passes a full buffer; this locks the clamp-no-OOB shape.
|
|
const std::vector<double> pcm = {1.0}; // 1 sample for a 2-frame mono request
|
|
auto wav = buildFloat32Wav(1, 44100, 2, pcm);
|
|
// Declared data size covers 2 frames; actual bytes stop after 1 sample, so the
|
|
// declared length overruns the buffer -> parse rejects (the honest verdict for
|
|
// a short-fed build; ingest never produces this).
|
|
CHECK(wav.size() == 44u + 4u);
|
|
CHECK(!parseWavLayout(wav).valid);
|
|
}
|
|
|
|
// --- hashBytes (FNV-1a content hash — moved with the impl from capture_paths) --
|
|
|
|
static void testHashBytesOutputFormat() {
|
|
// Output is always 16 lowercase hex characters.
|
|
const std::uint8_t bytes[] = {0x01, 0x02, 0x03};
|
|
const std::string h = hashBytes(bytes, 3);
|
|
CHECK(h.size() == 16);
|
|
for (char c : h) {
|
|
CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
|
|
}
|
|
}
|
|
|
|
static void testHashBytesDeterministicAndDistinct() {
|
|
// Same input always produces the same output; different inputs differ (no
|
|
// accidental dedup of distinct files), including a one-bit flip and a
|
|
// trailing-zero-byte length change.
|
|
const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01};
|
|
CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5));
|
|
|
|
std::uint8_t a[] = {0x00, 0x00};
|
|
std::uint8_t b[] = {0x00, 0x01};
|
|
CHECK(hashBytes(a, 2) != hashBytes(b, 2));
|
|
|
|
const std::uint8_t short_buf[] = {0xAB, 0xCD};
|
|
const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00};
|
|
CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3));
|
|
}
|
|
|
|
static void testHashBytesEmptyBufferIsNonEmpty() {
|
|
// An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty
|
|
// sentinel) — capturing the contract that even empty inputs yield a 16-char hash.
|
|
const std::string h = hashBytes(nullptr, 0);
|
|
CHECK(h.size() == 16);
|
|
}
|
|
|
|
// --- hashWavContent (WAV-aware dedup hash) -----------------------------------
|
|
//
|
|
// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata
|
|
// chunks like bext/LIST), falls back gracefully for non-WAV input, and that
|
|
// different audio data yields different hashes.
|
|
|
|
// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk
|
|
// inserted between "WAVE" and the fmt chunk. This is the shape REAPER produces: a
|
|
// `bext` or `LIST` chunk before fmt with a render-time timestamp in the body.
|
|
static std::vector<std::uint8_t> buildMetaWav(
|
|
std::uint16_t channels, std::uint32_t sampleRate,
|
|
const std::vector<float>& samples,
|
|
bool insertMeta = false,
|
|
const char* metaTag = "bext",
|
|
const std::vector<std::uint8_t>& metaBody = {}) {
|
|
|
|
std::vector<std::uint8_t> chunks;
|
|
|
|
if (insertMeta && !metaBody.empty()) {
|
|
putTag(chunks, metaTag);
|
|
putU32(chunks, static_cast<std::uint32_t>(metaBody.size()));
|
|
chunks.insert(chunks.end(), metaBody.begin(), metaBody.end());
|
|
if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad
|
|
}
|
|
|
|
// fmt chunk (16-byte body, IEEE-float tag 3).
|
|
const std::uint32_t dataBytes =
|
|
static_cast<std::uint32_t>(samples.size() * 4u);
|
|
putTag(chunks, "fmt ");
|
|
putU32(chunks, 16);
|
|
putU16(chunks, 3); // IEEE float
|
|
putU16(chunks, channels);
|
|
putU32(chunks, sampleRate);
|
|
putU32(chunks, sampleRate * channels * 4u); // byteRate
|
|
putU16(chunks, static_cast<std::uint16_t>(channels * 4)); // blockAlign
|
|
putU16(chunks, 32); // bitsPerSample
|
|
|
|
// data chunk.
|
|
putTag(chunks, "data");
|
|
putU32(chunks, dataBytes);
|
|
for (float f : samples) putFloat(chunks, f);
|
|
|
|
std::vector<std::uint8_t> wav;
|
|
putTag(wav, "RIFF");
|
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
|
putTag(wav, "WAVE");
|
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
|
return wav;
|
|
}
|
|
|
|
static void testHashWavContentIdenticalAudioSameHash() {
|
|
// Two WAVs with the same audio but different metadata body -> same hash.
|
|
// This is the core dedup regression: REAPER embeds a bext chunk with a
|
|
// render-time origination timestamp; without WAV-aware hashing, two renders
|
|
// of the same clip produce different file bytes -> no dedup collapse.
|
|
const std::vector<float> audio = {0.1f, -0.2f, 0.3f, -0.4f};
|
|
std::vector<std::uint8_t> metaA(64, 0x00); // bext body, all zeros (e.g. epoch)
|
|
std::vector<std::uint8_t> metaB(64, 0x00);
|
|
// Different origination timestamps: first 10 bytes of bext are ASCII date/time.
|
|
metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year
|
|
|
|
auto wavA = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaA);
|
|
auto wavB = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaB);
|
|
|
|
// Files must differ (the bext body is different) to prove the test is valid.
|
|
CHECK(wavA != wavB);
|
|
|
|
// But their content hashes must be equal: same fmt+data, different metadata.
|
|
CHECK(hashWavContent(wavA) == hashWavContent(wavB));
|
|
}
|
|
|
|
static void testHashWavContentDifferentAudioDifferentHash() {
|
|
// Different PCM data -> different content hashes (no false dedup).
|
|
const std::vector<float> audioA = {0.5f, 0.5f};
|
|
const std::vector<float> audioB = {0.5f, 0.6f}; // last sample differs
|
|
|
|
auto wavA = buildMetaWav(1, 44100, audioA);
|
|
auto wavB = buildMetaWav(1, 44100, audioB);
|
|
|
|
CHECK(hashWavContent(wavA) != hashWavContent(wavB));
|
|
}
|
|
|
|
static void testHashWavContentDifferentFmtDifferentHash() {
|
|
// Different fmt fields (sample rate) -> different content hashes.
|
|
const std::vector<float> audio = {0.1f, 0.2f};
|
|
auto wav44 = buildMetaWav(1, 44100, audio);
|
|
auto wav48 = buildMetaWav(1, 48000, audio);
|
|
CHECK(hashWavContent(wav44) != hashWavContent(wav48));
|
|
}
|
|
|
|
static void testHashWavContentNonWavFallsBackToWholeFile() {
|
|
// Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty
|
|
// and equals hashBytes of the same bytes directly. Empty input likewise.
|
|
std::vector<std::uint8_t> notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02};
|
|
const std::string h = hashWavContent(notWav);
|
|
CHECK(!h.empty());
|
|
CHECK(h.size() == 16);
|
|
CHECK(h == hashBytes(notWav.data(), notWav.size()));
|
|
|
|
std::vector<std::uint8_t> empty;
|
|
const std::string he = hashWavContent(empty);
|
|
CHECK(he.size() == 16);
|
|
CHECK(he == hashBytes(nullptr, 0));
|
|
}
|
|
|
|
static void testHashWavContentListMetaSkipped() {
|
|
// A LIST/INFO chunk (another common metadata chunk) is likewise skipped.
|
|
const std::vector<float> audio = {1.0f, -1.0f, 0.5f};
|
|
std::vector<std::uint8_t> listBody = {'I','N','F','O', 'x','x','x','x'};
|
|
auto wavClean = buildMetaWav(1, 48000, audio);
|
|
auto wavList = buildMetaWav(1, 48000, audio, true, "LIST", listBody);
|
|
|
|
// Content hashes must match: only the LIST chunk differs.
|
|
CHECK(hashWavContent(wavClean) == hashWavContent(wavList));
|
|
}
|
|
|
|
static void testHashWavContentDomainSeparationFromWholeFile() {
|
|
// The content hash ('W'-prefixed) must not accidentally equal the whole-file
|
|
// hash of the SAME bytes. This guards against the domain-separation prefix
|
|
// being dropped or zeroed out.
|
|
const std::vector<float> audio = {0.0f};
|
|
auto wav = buildMetaWav(1, 44100, audio);
|
|
const std::string contentHash = hashWavContent(wav);
|
|
const std::string wholeHash = hashBytes(wav.data(), wav.size());
|
|
CHECK(contentHash != wholeHash);
|
|
}
|
|
|
|
static void testHashMatchesBuildOutput() {
|
|
// The consolidation guarantee end-to-end: a WAV produced by the module's own
|
|
// builder hashes as WAV content (not the whole-file fallback), so an imported
|
|
// conversion and a captured render of identical audio can dedup-collapse.
|
|
const std::vector<double> pcm = {0.25, -0.25};
|
|
auto wav = buildFloat32Wav(1, 44100, 2, pcm);
|
|
CHECK(hashWavContent(wav) != hashBytes(wav.data(), wav.size())); // chunk-aware path taken
|
|
// And a metadata-bearing copy of the same audio content hashes identically.
|
|
auto withMeta = buildMetaWav(1, 44100, {0.25f, -0.25f}, true, "bext",
|
|
std::vector<std::uint8_t>(16, 0x7A));
|
|
CHECK(hashWavContent(wav) == hashWavContent(withMeta));
|
|
}
|
|
|
|
// Golden hash literal (review follow-up, Q-W3): the tests above prove format,
|
|
// determinism, and relational properties (equal/different) of hashBytes and
|
|
// hashWavContent, but none of them pins the actual output bytes — a feed-sequence
|
|
// change (e.g. dropping the 'W' domain-separation prefix, or reordering the
|
|
// fmt/data feed) would pass every test above while silently invalidating every
|
|
// contentHash already persisted in existing projects' BankIndex JSON. This test
|
|
// locks a fixed buildFloat32Wav input's hashes against exact hex literals
|
|
// captured from the current implementation, so any such drift fails loudly here.
|
|
static void testGoldenHashLiterals() {
|
|
const std::vector<double> pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125};
|
|
auto wav = buildFloat32Wav(2, 48000, 3, pcm);
|
|
CHECK(hashWavContent(wav) == "7ccf298c166a670a");
|
|
CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44");
|
|
}
|
|
|
|
// --- Lossless mono collapse --------------------------------------------------
|
|
|
|
// Every channel carries frame f's value; the collapse must keep those values verbatim
|
|
// in one channel and leave frame count / rate / bit depth alone.
|
|
static void testCollapseBitIdenticalStereo() {
|
|
auto wav = buildFloatWav(2, 48000, 6,
|
|
[](std::size_t f, std::uint16_t) {
|
|
return 0.25f * static_cast<float>(f) - 0.5f;
|
|
});
|
|
const MonoCollapse c = collapseToMono(wav);
|
|
CHECK(c.collapsed);
|
|
|
|
const WavLayout L = parseWavLayout(c.bytes);
|
|
CHECK(L.valid); // valid implies float32: the parser rejects anything else
|
|
CHECK(L.channelCount == 1);
|
|
CHECK(L.sampleRate == 48000);
|
|
CHECK(L.frameCount() == 6);
|
|
|
|
const auto pcm = extractFloatFrames(c.bytes, L, 0, 6);
|
|
CHECK(pcm.size() == 6);
|
|
for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
|
|
CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
|
|
}
|
|
|
|
static void testCollapseDeclinesOnOneDifferingSample() {
|
|
// Identical everywhere except frame 4's right channel, by the smallest step the
|
|
// format can express near 1.0.
|
|
auto wav = buildFloatWav(2, 48000, 8,
|
|
[](std::size_t f, std::uint16_t ch) {
|
|
float v = 1.0f + static_cast<float>(f);
|
|
if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
|
|
return v;
|
|
});
|
|
CHECK(!collapseToMono(wav).collapsed);
|
|
CHECK(collapseToMono(wav).bytes.empty());
|
|
}
|
|
|
|
// An already-mono file must come back untouched — a second capture pass over a
|
|
// collapsed file must not rebuild (and so must not re-hash) it.
|
|
static void testCollapseDeclinesOnAlreadyMono() {
|
|
auto wav = buildFloatWav(1, 44100, 4,
|
|
[](std::size_t f, std::uint16_t) {
|
|
return static_cast<float>(f);
|
|
});
|
|
CHECK(!collapseToMono(wav).collapsed);
|
|
}
|
|
|
|
// N-channel generalization: all-identical collapses to ONE channel, never a partial
|
|
// fold (4 -> 2). Unreachable from today's capture paths, which always render 2.
|
|
static void testCollapseFourChannels() {
|
|
auto same = buildFloatWav(4, 48000, 5,
|
|
[](std::size_t f, std::uint16_t) {
|
|
return -0.125f * static_cast<float>(f);
|
|
});
|
|
const MonoCollapse c = collapseToMono(same);
|
|
CHECK(c.collapsed);
|
|
const WavLayout L = parseWavLayout(c.bytes);
|
|
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 5);
|
|
|
|
auto oneDiffers = buildFloatWav(4, 48000, 5,
|
|
[](std::size_t f, std::uint16_t ch) {
|
|
float v = -0.125f * static_cast<float>(f);
|
|
if (f == 2 && ch == 3) v += 0.5f;
|
|
return v;
|
|
});
|
|
CHECK(!collapseToMono(oneDiffers).collapsed);
|
|
}
|
|
|
|
static void testCollapseZeroAndSingleFrame() {
|
|
// No frame of evidence that the channels agree -> decline rather than rebuild.
|
|
auto empty = buildFloatWav(2, 48000, 0,
|
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
|
CHECK(parseWavLayout(empty).valid && parseWavLayout(empty).frameCount() == 0);
|
|
CHECK(!collapseToMono(empty).collapsed);
|
|
|
|
auto one = buildFloatWav(2, 48000, 1,
|
|
[](std::size_t, std::uint16_t) { return 0.75f; });
|
|
const MonoCollapse c = collapseToMono(one);
|
|
CHECK(c.collapsed);
|
|
const WavLayout L = parseWavLayout(c.bytes);
|
|
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
|
|
const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
|
|
CHECK(pcm.size() == 1 && pcm[0] == 0.75f);
|
|
}
|
|
|
|
// The predicate is over BIT PATTERNS: -0.0f == +0.0f compares equal as floats but is
|
|
// a different value on disk, so folding it would not be lossless.
|
|
static void testCollapseSignedZeroIsNotIdentical() {
|
|
auto wav = buildFloatWav(2, 48000, 3,
|
|
[](std::size_t, std::uint16_t ch) {
|
|
return ch == 0 ? 0.0f : -0.0f;
|
|
});
|
|
CHECK(!collapseToMono(wav).collapsed);
|
|
}
|
|
|
|
// A leading odd-sized chunk exercises the walk's RIFF pad byte; the rebuilt file is
|
|
// canonical, so that chunk does not survive.
|
|
static void testCollapseThroughOddPaddedLeadingChunk() {
|
|
std::vector<std::uint8_t> chunks;
|
|
putTag(chunks, "LIST");
|
|
putU32(chunks, 5); // odd body -> one pad byte
|
|
for (int i = 0; i < 5; ++i) chunks.push_back(0x41);
|
|
chunks.push_back(0); // the pad
|
|
putTag(chunks, "fmt ");
|
|
putU32(chunks, 16);
|
|
putU16(chunks, 3);
|
|
putU16(chunks, 2);
|
|
putU32(chunks, 48000);
|
|
putU32(chunks, 48000u * 2u * 4u);
|
|
putU16(chunks, 8);
|
|
putU16(chunks, 32);
|
|
putTag(chunks, "data");
|
|
putU32(chunks, 3u * 2u * 4u);
|
|
for (std::size_t f = 0; f < 3; ++f)
|
|
for (int ch = 0; ch < 2; ++ch) putFloat(chunks, 0.5f * static_cast<float>(f));
|
|
|
|
std::vector<std::uint8_t> wav;
|
|
putTag(wav, "RIFF");
|
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
|
putTag(wav, "WAVE");
|
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
|
|
|
const MonoCollapse c = collapseToMono(wav);
|
|
CHECK(c.collapsed);
|
|
const WavLayout L = parseWavLayout(c.bytes);
|
|
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 3);
|
|
// Canonical rebuild: byte-for-byte what buildFloat32Wav produces for the same PCM.
|
|
CHECK(c.bytes == buildFloat32Wav(1, 48000, 3, {0.0, 0.5, 1.0}));
|
|
}
|
|
|
|
static void testCollapseDeclinesOnUnparseableBytes() {
|
|
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
|
CHECK(!collapseToMono(junk).collapsed);
|
|
CHECK(!collapseToMono(std::vector<std::uint8_t>{}).collapsed);
|
|
}
|
|
|
|
// Stated consequence, pinned: the collapse rewrites both the `fmt ` body and the
|
|
// `data` payload, so a collapsed capture no longer shares content identity with the
|
|
// stereo file it came from and will not dedup against one already in the bank.
|
|
static void testCollapseChangesContentHash() {
|
|
auto wav = buildFloatWav(2, 48000, 4,
|
|
[](std::size_t f, std::uint16_t) {
|
|
return static_cast<float>(f);
|
|
});
|
|
const MonoCollapse c = collapseToMono(wav);
|
|
CHECK(c.collapsed);
|
|
CHECK(hashWavContent(c.bytes) != hashWavContent(wav));
|
|
}
|
|
|
|
int main() {
|
|
testParseCanonicalStereo();
|
|
testParseMonoAndLeadingChunk();
|
|
testParseRejectsNon32BitAndNonWav();
|
|
testParseRejectsLyingDataLength();
|
|
testExtractTailWindow();
|
|
testTruncatePlanKeepFewer();
|
|
testTruncatePlanKeepAllIsNoOp();
|
|
testTruncatePlanKeepZeroAndGrowRejected();
|
|
testExtensiblePcmIntegerRejected();
|
|
testExtensibleFloatAccepted();
|
|
testPatchU32LEWritesLittleEndian();
|
|
testBuildFloat32WavGoldenHeaderAndRoundTrip();
|
|
testBuildFloat32WavShortInputRejectedByParse();
|
|
testHashBytesOutputFormat();
|
|
testHashBytesDeterministicAndDistinct();
|
|
testHashBytesEmptyBufferIsNonEmpty();
|
|
testHashWavContentIdenticalAudioSameHash();
|
|
testHashWavContentDifferentAudioDifferentHash();
|
|
testHashWavContentDifferentFmtDifferentHash();
|
|
testHashWavContentNonWavFallsBackToWholeFile();
|
|
testHashWavContentListMetaSkipped();
|
|
testHashWavContentDomainSeparationFromWholeFile();
|
|
testHashMatchesBuildOutput();
|
|
testGoldenHashLiterals();
|
|
testCollapseBitIdenticalStereo();
|
|
testCollapseDeclinesOnOneDifferingSample();
|
|
testCollapseDeclinesOnAlreadyMono();
|
|
testCollapseFourChannels();
|
|
testCollapseZeroAndSingleFrame();
|
|
testCollapseSignedZeroIsNotIdentical();
|
|
testCollapseThroughOddPaddedLeadingChunk();
|
|
testCollapseDeclinesOnUnparseableBytes();
|
|
testCollapseChangesContentHash();
|
|
|
|
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
|
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
|
return g_fail ? 1 : 0;
|
|
}
|