// Unit tests for the JRay v1 audio signature (src/audio_signature.*). // /// TRACES: UT-101, UT-102, UT-103, UT-104 | IR-004, IR-005, IR-007, IR-008 // // The headline test is the golden vector: a deterministic tone fixture checked // into tests/fixtures/audio/ together with the signature it must produce. That // fixture is the artefact shared with the jRay plugin repo, and it is what // makes "both producers agree bit-for-bit" a checked claim rather than an // assertion (IR-005). // // GPU-free, model-free, no network. Pure CPU DSP plus an FFmpeg decode of a // 585 KB file — which is precisely why this is the right cross-repo check: it // runs anywhere, including the N100 CI host. #include #include #include "audio_signature.hpp" #include #include #include #include #include #include #include #include using namespace sae::audio; namespace fs = std::filesystem; namespace { const std::string kFixtureDir = SAE_TEST_FIXTURES_DIR "/audio"; const std::string kGoldenPath = kFixtureDir + "/jray_audio_v1_golden.json"; const std::string kMediaPath = kFixtureDir + "/jray_audio_v1_tone.flac"; const nlohmann::json& golden() { static const nlohmann::json j = [] { std::ifstream in(kGoldenPath); if (!in.good()) throw std::runtime_error("golden fixture not found: " + kGoldenPath); nlohmann::json parsed; in >> parsed; return parsed; }(); return j; } std::uint64_t hex64(const std::string& s) { return std::stoull(s, nullptr, 16); } // The fixture is 120 s of audio: decoding and signing it is the expensive part // of this file, so both results are computed once and shared. Every test below // still asserts against the on-disk golden values, not against each other. const std::optional>& fixture_window() { static const std::optional> w = decode_centre_window(kMediaPath); return w; } const std::optional& fixture_signature() { static const std::optional s = compute_signature(kMediaPath); return s; } // ── Minimal WAV writer, so the short-media and resample cases need no fixture ─ // 16-bit PCM, interleaved. struct TempWav { fs::path path; explicit TempWav(const std::string& name) : path(fs::temp_directory_path() / ("sae_audio_test_" + name + ".wav")) {} ~TempWav() { std::error_code ec; fs::remove(path, ec); } void write(const std::vector& samples, int rate, int channels) const { const std::uint32_t bytes = static_cast(samples.size() * 2); const std::uint32_t byte_rate = static_cast(rate * channels * 2); std::ofstream out(path, std::ios::binary); auto u32 = [&](std::uint32_t v) { out.write(reinterpret_cast(&v), 4); }; auto u16 = [&](std::uint16_t v) { out.write(reinterpret_cast(&v), 2); }; out.write("RIFF", 4); u32(36 + bytes); out.write("WAVE", 4); out.write("fmt ", 4); u32(16); u16(1); u16(static_cast(channels)); u32(static_cast(rate)); u32(byte_rate); u16(static_cast(channels * 2)); u16(16); out.write("data", 4); u32(bytes); out.write(reinterpret_cast(samples.data()), bytes); } }; // A plain 1 kHz tone, mono, at the signature's own rate. std::vector tone(double seconds, int rate = kSampleRate) { const std::size_t n = static_cast(std::llround(seconds * rate)); std::vector s(n); for (std::size_t i = 0; i < n; ++i) s[i] = static_cast(std::llround( 20000.0 * std::sin(2.0 * 3.14159265358979323846 * 1000.0 * double(i) / rate))); return s; } std::vector base64_decode(const std::string& in) { auto val = [](char c) -> int { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26; if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63; return -1; }; std::vector out; std::uint32_t acc = 0; int bits = 0; for (char c : in) { const int v = val(c); if (v < 0) continue; // '=' padding acc = (acc << 6) | static_cast(v); bits += 6; if (bits >= 8) { bits -= 8; out.push_back(static_cast((acc >> bits) & 0xFF)); } } return out; } } // namespace // ── UT-101 — the golden vector ────────────────────────────────────────────── /// TRACES: UT-101 | IR-004, IR-005, IR-008 TEST_CASE("signature of the golden fixture matches the recorded value exactly", "[audio_signature][golden]") { REQUIRE(fs::exists(kMediaPath)); const std::optional& sig = fixture_signature(); REQUIRE(sig.has_value()); CHECK(*sig == golden()["signature"].get()); } /// TRACES: UT-101 | IR-005 TEST_CASE("decoded centre window matches the recorded PCM checksum", "[audio_signature][golden]") { // Checked separately from the signature so a codec-level difference is // distinguishable from a DSP-level one: if this passes and the signature // test fails, the DSP diverged; if this fails, the decode did. const std::optional>& mono = fixture_window(); REQUIRE(mono.has_value()); CHECK(mono->size() == golden()["decoded_window"]["samples"].get()); CHECK(fnv1a64(mono->data(), mono->size() * sizeof(float)) == hex64(golden()["decoded_window"]["f32le_fnv1a64"].get())); } /// TRACES: UT-101 | IR-004 TEST_CASE("log-spaced band table matches the recorded one", "[audio_signature][golden]") { // The band->FFT-bin table is the part of the construction most likely to // drift between two implementations, so it is pinned independently of the // signature it produces. const auto& tbl = band_fft_bins(); const auto& want = golden()["band_fft_bins"]; REQUIRE(want.size() == tbl.size()); for (std::size_t b = 0; b < tbl.size(); ++b) { CHECK(tbl[b].first == want[b][0].get()); CHECK(tbl[b].second == want[b][1].get()); CHECK(tbl[b].second > tbl[b].first); // no empty band if (b) CHECK(tbl[b].first == tbl[b - 1].second); // contiguous, no overlap } } /// TRACES: UT-101 | IR-004, IR-008 TEST_CASE("signature is well-formed: v1 prefix, 1288 frames, structural bytes", "[audio_signature][golden]") { const std::optional& sig = fixture_signature(); REQUIRE(sig.has_value()); // IR-008 — the signature carries its own version, separate from // schema_version, so a future DSP change is detectable rather than silently // producing non-matching signatures. REQUIRE(sig->rfind(kVersionPrefix, 0) == 0); const std::vector bytes = base64_decode(sig->substr(3)); CHECK(bytes.size() == kExpectedFrames); CHECK(bytes.size() == golden()["frame_count"].get()); // The server validates this structure on upload (server SPEC §3): each byte // is a 5-bit band index plus a 2-bit energy class, so bit 7 is always clear // and arbitrary bytes are invalid. That is what keeps the field from being // a payload channel. bool bands_seen[kNumBands] = {}; bool classes_seen[4] = {}; for (std::uint8_t b : bytes) { REQUIRE((b & 0x80) == 0); bands_seen[(b >> 2) & 0x1F] = true; classes_seen[b & 0x03] = true; } // The fixture is built to exercise the whole output alphabet — if it ever // stops doing so, the golden vector has become a weaker check than it looks. for (bool seen : bands_seen) CHECK(seen); for (bool seen : classes_seen) CHECK(seen); } // ── UT-102 — IR-007, media shorter than the window ────────────────────────── /// TRACES: UT-102 | IR-007 TEST_CASE("media shorter than 120 s emits no signature", "[audio_signature][short]") { // The window runtime/2 ± 60 s underflows, so there is no signature and no // sync offset downstream. Both producers must apply the identical rule or // they diverge on exactly the short items most likely to be misidentified. SECTION("30 s") { TempWav w("short30"); w.write(tone(30.0), kSampleRate, 1); CHECK_FALSE(compute_signature(w.path.string()).has_value()); CHECK_FALSE(decode_centre_window(w.path.string()).has_value()); } SECTION("just under the boundary") { TempWav w("short11999"); w.write(tone(119.99), kSampleRate, 1); CHECK_FALSE(compute_signature(w.path.string()).has_value()); } } /// TRACES: UT-102 | IR-007 TEST_CASE("media of exactly 120 s emits a full-length signature", "[audio_signature][short]") { TempWav w("exact120"); w.write(tone(120.0), kSampleRate, 1); const std::optional sig = compute_signature(w.path.string()); REQUIRE(sig.has_value()); CHECK(base64_decode(sig->substr(3)).size() == kExpectedFrames); } /// TRACES: UT-102 | IR-007 TEST_CASE("unreadable media degrades to no signature rather than failing", "[audio_signature][short]") { // UR-9 is an enhancement and must never be able to break a fetch. CHECK_FALSE(compute_signature("/nonexistent/definitely-not-here.mkv").has_value()); } /// TRACES: UT-102 | IR-004 TEST_CASE("the window is taken from the centre, not the head", "[audio_signature][centre]") { // Sampling from the centre is the whole reason the construction avoids the // head and tail (logos, cold opens, credits), so it needs its own check: // wrap the fixture's own 120 s in 90 s of silence either side and the // signature of the 300 s file must be the golden value, byte for byte. // Nothing else pins the seek offset — a head-anchored window would pass // every other test in this file. const std::optional>& mono = fixture_window(); REQUIRE(mono.has_value()); const std::size_t pad = 90 * kSampleRate; std::vector padded(pad * 2 + mono->size(), 0); for (std::size_t i = 0; i < mono->size(); ++i) padded[pad + i] = static_cast(std::llround(double((*mono)[i]) * 32768.0)); TempWav w("centred300"); w.write(padded, kSampleRate, 1); const std::optional sig = compute_signature(w.path.string()); REQUIRE(sig.has_value()); CHECK(*sig == golden()["signature"].get()); } // ── UT-103 — downmix and resample ─────────────────────────────────────────── /// TRACES: UT-103 | IR-004 TEST_CASE("stereo, non-native sample rate yields the same peak-bin sequence", "[audio_signature][resample]") { // The golden fixture is already mono at 11025 Hz so the golden vector does // not depend on the resampler's version. This case exercises the path that // real media takes — downmix plus resample — by rebuilding the fixture's own // audio as 22050 Hz stereo and checking the peak bins survive it. const std::optional>& mono = fixture_window(); REQUIRE(mono.has_value()); std::vector stereo; stereo.reserve(mono->size() * 4); for (float f : *mono) { const auto s = static_cast(std::llround(double(f) * 32768.0)); stereo.push_back(s); stereo.push_back(s); // sample 1, L/R stereo.push_back(s); stereo.push_back(s); // sample 2 (zero-order hold) } TempWav w("stereo22050"); w.write(stereo, 2 * kSampleRate, 2); const std::optional sig = compute_signature(w.path.string()); REQUIRE(sig.has_value()); const std::vector got = base64_decode(sig->substr(3)); const std::vector want = base64_decode(golden()["signature"].get().substr(3)); REQUIRE(got.size() == want.size()); std::size_t agree = 0; for (std::size_t i = 0; i < got.size(); ++i) agree += ((got[i] >> 2) == (want[i] >> 2)) ? 1 : 0; // The server treats ≥ 0.85 as the `audio` match tier; this path scores 1.0 // in practice, and the margin is left for libswresample version drift. CHECK(double(agree) / double(got.size()) >= 0.85); } // ── UT-104 — the pure DSP surface ─────────────────────────────────────────── /// TRACES: UT-104 | IR-004 TEST_CASE("pack_frames uses whole frames only", "[audio_signature][dsp]") { CHECK(pack_frames(std::vector(kFrameSize - 1, 0.f)).empty()); CHECK(pack_frames(std::vector(kFrameSize, 0.f)).size() == 1); CHECK(pack_frames(std::vector(kFrameSize + kHopSize - 1, 0.f)).size() == 1); CHECK(pack_frames(std::vector(kFrameSize + kHopSize, 0.f)).size() == 2); // The full 120 s window is 1288 frames — asserted as a constant rather than // by running the DSP over 1.3 M zeros, which is the same claim for free. CHECK(kWindowSamples == 1323000u); CHECK(kExpectedFrames == 1288u); CHECK_FALSE(signature_from_mono(std::vector(kFrameSize - 1, 0.f)).has_value()); } /// TRACES: UT-104 | IR-004 TEST_CASE("a pure tone lands in the band that contains it", "[audio_signature][dsp]") { // 1000 Hz sits in log-band floor(32 * log10(1000/300)) = 16. const int expect = static_cast(std::floor( kNumBands * std::log10(1000.0 / kBandLoHz) / std::log10(kBandHiHz / kBandLoHz))); std::vector mono(kWindowSamples / 100); for (std::size_t i = 0; i < mono.size(); ++i) mono[i] = static_cast(0.5 * std::sin( 2.0 * 3.14159265358979323846 * 1000.0 * double(i) / kSampleRate)); const std::vector packed = pack_frames(mono); REQUIRE_FALSE(packed.empty()); for (std::uint8_t b : packed) CHECK(((b >> 2) & 0x1F) == expect); } /// TRACES: UT-104 | IR-004 TEST_CASE("signature is invariant to overall gain", "[audio_signature][dsp]") { // Loudness normalisation between two releases of the same cut must not // change the signature — that is why the energy class is relative. std::vector a(kWindowSamples / 50); for (std::size_t i = 0; i < a.size(); ++i) { const double t = double(i) / kSampleRate; a[i] = static_cast(0.4 * std::sin(2.0 * 3.14159265358979323846 * 640.0 * t) + 0.2 * std::sin(2.0 * 3.14159265358979323846 * 1900.0 * t) * std::sin(2.0 * 3.14159265358979323846 * 0.7 * t)); } std::vector b(a.size()); for (std::size_t i = 0; i < a.size(); ++i) b[i] = a[i] * 0.25f; CHECK(pack_frames(a) == pack_frames(b)); } /// TRACES: UT-104 | IR-004 TEST_CASE("base64 encoder matches the standard alphabet and padding", "[audio_signature][dsp]") { auto enc = [](const std::string& s) { return base64_encode(reinterpret_cast(s.data()), s.size()); }; CHECK(enc("") == ""); CHECK(enc("f") == "Zg=="); CHECK(enc("fo") == "Zm8="); CHECK(enc("foo") == "Zm9v"); CHECK(enc("foob") == "Zm9vYg=="); CHECK(enc("fooba") == "Zm9vYmE="); CHECK(enc("foobar") == "Zm9vYmFy"); const std::uint8_t all[] = {0xFB, 0xFF, 0xBF}; // exercises '+' and '/' CHECK(base64_encode(all, 3) == "+/+/"); }