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:
@@ -5,11 +5,8 @@
|
||||
|
||||
#include "../src/core/capture/capture_paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring> // std::memcpy (for putF32cp in hashWavContent tests)
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::capture;
|
||||
@@ -345,208 +342,9 @@ static void testTransitionInPlaceSaveIsNoOp() {
|
||||
== ProjectTransition::NoOp);
|
||||
}
|
||||
|
||||
// --- hashBytes (FNV-1a content hash) ----------------------------------------
|
||||
//
|
||||
// The fix for the confirm-on-last-reference bug: hashBytes produces a 16-char hex
|
||||
// string that capture.cpp and capture_realtime.cpp store on Sample::contentHash so
|
||||
// BankBook::hashReferencedElsewhere can detect copies and suppress the confirm when
|
||||
// another bank still holds the same file.
|
||||
|
||||
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 testHashBytesDeterministic() {
|
||||
// Same input always produces the same output (bit-identical captures get
|
||||
// the same hash, so hashReferencedElsewhere fires correctly for copies).
|
||||
const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01};
|
||||
CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5));
|
||||
}
|
||||
|
||||
static void testHashBytesDistinct() {
|
||||
// Different inputs produce different hashes (no accidental dedup of distinct
|
||||
// files). This covers the "one-bit-flip changes the hash" property.
|
||||
std::uint8_t a[] = {0x00, 0x00};
|
||||
std::uint8_t b[] = {0x00, 0x01};
|
||||
CHECK(hashBytes(a, 2) != hashBytes(b, 2));
|
||||
|
||||
std::uint8_t c[] = {0xFF, 0xFF, 0xFF};
|
||||
std::uint8_t d[] = {0xFF, 0xFF, 0xFE};
|
||||
CHECK(hashBytes(c, 3) != hashBytes(d, 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);
|
||||
}
|
||||
|
||||
static void testHashBytesLargerBufferDiffersFromSmaller() {
|
||||
// Padding a buffer with a zero byte must change the hash (order + length
|
||||
// sensitivity so two differently-sized WAV files don't accidentally collide).
|
||||
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));
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
|
||||
// Minimal synthetic WAV builder (mirrors the one in test_wav_trim.cpp).
|
||||
static void putU16cp(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 putU32cp(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 putTagcp(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 putF32cp(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]);
|
||||
}
|
||||
|
||||
// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk
|
||||
// inserted between "WAVE" and the fmt chunk. `metaChunkBody` and `metaTag` are
|
||||
// used when `insertMeta` is true. 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> buildTestWav(
|
||||
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()) {
|
||||
putTagcp(chunks, metaTag);
|
||||
putU32cp(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);
|
||||
putTagcp(chunks, "fmt ");
|
||||
putU32cp(chunks, 16);
|
||||
putU16cp(chunks, 3); // IEEE float
|
||||
putU16cp(chunks, channels);
|
||||
putU32cp(chunks, sampleRate);
|
||||
putU32cp(chunks, sampleRate * channels * 4u); // byteRate
|
||||
putU16cp(chunks, static_cast<std::uint16_t>(channels * 4)); // blockAlign
|
||||
putU16cp(chunks, 32); // bitsPerSample
|
||||
|
||||
// data chunk.
|
||||
putTagcp(chunks, "data");
|
||||
putU32cp(chunks, dataBytes);
|
||||
for (float f : samples) putF32cp(chunks, f);
|
||||
|
||||
std::vector<std::uint8_t> wav;
|
||||
putTagcp(wav, "RIFF");
|
||||
putU32cp(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
||||
putTagcp(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 = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaA);
|
||||
auto wavB = buildTestWav(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 = buildTestWav(1, 44100, audioA);
|
||||
auto wavB = buildTestWav(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 = buildTestWav(1, 44100, audio);
|
||||
auto wav48 = buildTestWav(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.
|
||||
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()));
|
||||
}
|
||||
|
||||
static void testHashWavContentEmptyFallsBackToHashBytes() {
|
||||
// Empty vector -> falls back to whole-file hashBytes (the FNV offset basis).
|
||||
std::vector<std::uint8_t> empty;
|
||||
const std::string h = hashWavContent(empty);
|
||||
CHECK(!h.empty());
|
||||
CHECK(h.size() == 16);
|
||||
CHECK(h == 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 = buildTestWav(1, 48000, audio);
|
||||
auto wavList = buildTestWav(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 = buildTestWav(1, 44100, audio);
|
||||
const std::string contentHash = hashWavContent(wav);
|
||||
const std::string wholeHash = hashBytes(wav.data(), wav.size());
|
||||
CHECK(contentHash != wholeHash);
|
||||
}
|
||||
// NOTE (Q-W3, audit §4e): the hashBytes / hashWavContent tests moved to
|
||||
// tests/test_wav_codec.cpp with the implementations — capture_paths is now path
|
||||
// arithmetic only, with no content-hash / RIFF knowledge.
|
||||
|
||||
// --- bankRelativeForName spelling consistency (Phase R, R2) -----------------
|
||||
//
|
||||
@@ -597,18 +395,6 @@ int main() {
|
||||
testTransitionTwoUnsavedProjectsSwitchLoads();
|
||||
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
|
||||
testTransitionInPlaceSaveIsNoOp();
|
||||
testHashBytesOutputFormat();
|
||||
testHashBytesDeterministic();
|
||||
testHashBytesDistinct();
|
||||
testHashBytesEmptyBufferIsNonEmpty();
|
||||
testHashBytesLargerBufferDiffersFromSmaller();
|
||||
testHashWavContentIdenticalAudioSameHash();
|
||||
testHashWavContentDifferentAudioDifferentHash();
|
||||
testHashWavContentDifferentFmtDifferentHash();
|
||||
testHashWavContentNonWavFallsBackToWholeFile();
|
||||
testHashWavContentEmptyFallsBackToHashBytes();
|
||||
testHashWavContentListMetaSkipped();
|
||||
testHashWavContentDomainSeparationFromWholeFile();
|
||||
testBankRelativeForNameMatchesDerivePathSpelling();
|
||||
testBankRelativeForNameConventionAndEdge();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user