// Unit tests for the engine's self-contained parts: no test data files, no // reference implementation, no external framework. Run with `ctest` or directly. #include #include #include #include #include #include #include #include #include "adm/adm_metadata.h" #include "eac3_transport/eac3_reader.h" #include "foundation/bit_reader.h" #include "foundation/fs_utf8.h" #include "foundation/geometry.h" #include "hrtf/jochrtf.h" #include "hrtf/sofa.h" #include "hrtf/sofa_cache.h" #include "hrtf/rosella_model.h" #include "hrtf/rosella_renderer.h" #include "hrtf/sofa_field.h" #include "foundation/mini_json.h" #include "foundation/sha256.h" #include "io/inflate.h" #include "io/hdf5.h" #include "io/npy_writer.h" #include "io/npy.h" #include "io/process.h" #include "io/wav_writer.h" #include "io/zip_reader.h" #include "oamd/oamd_parser.h" namespace { int g_failures = 0; int g_checks = 0; void check(bool condition, const char* expression, const char* file, int line) { ++g_checks; if (!condition) { ++g_failures; std::printf("FAIL %s:%d %s\n", file, line, expression); } } #define CHECK(expression) check((expression), #expression, __FILE__, __LINE__) void test_sha256() { CHECK(joc::crypto::sha256_hex("", 0) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); CHECK(joc::crypto::sha256_hex("abc", 3) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); const char* two_block = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; CHECK(joc::crypto::sha256_hex(two_block, std::strlen(two_block)) == "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); std::vector million(1000000, 'a'); CHECK(joc::crypto::sha256_hex(million.data(), million.size()) == "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); // Incremental updates must equal a single-shot hash. joc::crypto::Sha256 incremental; incremental.update("ab", 2); incremental.update("c", 1); CHECK(incremental.finish_hex() == joc::crypto::sha256_hex("abc", 3)); } void test_crc32_and_inflate() { CHECK(joc::io::crc32_of(reinterpret_cast("123456789"), 9) == 0xCBF43926u); // One stored DEFLATE block: BFINAL=1, BTYPE=00, LEN=3, NLEN=~LEN, "abc". const std::uint8_t stored[] = {0x01, 0x03, 0x00, 0xFC, 0xFF, 'a', 'b', 'c'}; std::vector out; CHECK(joc::io::inflate_raw(stored, sizeof(stored), &out)); CHECK(out.size() == 3 && std::memcmp(out.data(), "abc", 3) == 0); const std::uint8_t truncated[] = {0x01, 0x10, 0x00, 0xEF, 0xFF, 'a'}; CHECK(!joc::io::inflate_raw(truncated, sizeof(truncated), &out)); } void test_bit_reader() { const std::uint8_t data[] = {0xB2, 0x5D}; // 1011 0010 0101 1101 joc::bits::BitReader reader(data, sizeof(data)); CHECK(reader.read(3) == 0x5); CHECK(reader.read(5) == 0x12); CHECK(reader.read(8) == 0x5D); CHECK(reader.position() == 16); CHECK(!reader.failed()); joc::bits::BitReader short_read(data, 1); CHECK(short_read.read(9) == 0); CHECK(short_read.failed()); CHECK(short_read.error() == JOC_ERR_BITSTREAM_TRUNCATED); CHECK(short_read.position() == 0); joc::bits::BitReader limited(data, sizeof(data)); limited.set_limit_bits(4); CHECK(limited.read(4) == 0xB); CHECK(limited.read(1) == 0 && limited.failed()); } // Writes MSB-first bits; used only to build test vectors. class BitWriter { public: void write(std::uint32_t value, unsigned count) { for (unsigned i = count; i-- > 0;) { const std::uint8_t bit = static_cast((value >> i) & 1u); if (used_ == 0) { bytes_.push_back(0); } bytes_.back() |= static_cast(bit << (7u - used_)); used_ = (used_ + 1u) % 8u; } } const std::vector& bytes() const { return bytes_; } private: std::vector bytes_; unsigned used_ = 0; }; void test_variable_bits() { // 300 with width 8 is two groups: 0 (continue) then 44 (stop). BitWriter writer; writer.write(0, 8); writer.write(1, 1); writer.write(44, 8); writer.write(0, 1); joc::bits::BitReader reader(writer.bytes().data(), writer.bytes().size()); std::uint32_t value = 0; CHECK(joc::bits::variable_bits(reader, 8, 8, &value)); CHECK(value == 300); // Eight continuation groups exceed the limit and must fail, not loop. BitWriter endless; for (int group = 0; group < 8; ++group) { endless.write(0, 8); endless.write(1, 1); } joc::bits::BitReader endless_reader(endless.bytes().data(), endless.bytes().size()); CHECK(!joc::bits::variable_bits(endless_reader, 8, 8, &value)); CHECK(endless_reader.error() == JOC_ERR_EMDF_SYNTAX); } void test_eac3_frame_bytes() { std::vector frame(3072, 0); frame[0] = 0x0B; frame[1] = 0x77; frame[2] = 0x05; frame[3] = 0xFF; // frmsiz words = 1536 -> 3072 bytes std::size_t size = 0; CHECK(joc::eac3::FrameReader::frame_bytes(frame.data(), frame.size(), 0, &size) == JOC_OK); CHECK(size == 3072); CHECK(joc::eac3::FrameReader::frame_bytes(frame.data(), 100, 0, &size) == JOC_ERR_BITSTREAM_TRUNCATED); frame[1] = 0x78; CHECK(joc::eac3::FrameReader::frame_bytes(frame.data(), frame.size(), 0, &size) == JOC_ERR_EAC3_SYNCFRAME); } void test_mini_json() { const std::string text = R"({"a":1,"b":"x\ny","c":{"d":true},"e":-2.5})"; std::vector members; std::string error; CHECK(joc::json::parse_object(text, &members, &error)); double number = 0.0; CHECK(joc::json::as_number(*joc::json::find(members, "a"), &number) && number == 1.0); CHECK(joc::json::as_number(*joc::json::find(members, "e"), &number) && number == -2.5); std::string value; CHECK(joc::json::as_string(*joc::json::find(members, "b"), &value) && value == "x\ny"); CHECK(joc::json::find(members, "c")->raw == R"({"d":true})"); std::vector duplicate; CHECK(!joc::json::parse_object(R"({"a":1,"a":2})", &duplicate, &error)); CHECK(!joc::json::parse_object("[1,2]", &duplicate, &error)); } void test_npy() { // Build a minimal image; const char magic[6] = {'\x93', 'N', 'U', 'M', 'P', 'Y'}; image.insert(image.end(), magic, magic + 6); image.push_back(1); image.push_back(0); image.push_back(static_cast(header.size() & 0xFFu)); image.push_back(static_cast(header.size() >> 8)); image.insert(image.end(), header.begin(), header.end()); const double values[2] = {1.5, -0.25}; const std::uint8_t* raw = reinterpret_cast(values); image.insert(image.end(), raw, raw + sizeof(values)); joc::io::NpyArray array; std::string error; CHECK(joc::io::parse_npy(image.data(), image.size(), &array, &error)); CHECK(array.descr == " loaded; CHECK(joc::io::npy_to_double(array, &loaded, &error)); CHECK(loaded.size() == 2 && loaded[0] == 1.5 && loaded[1] == -0.25); } void test_oamd_and_adm_helpers() { CHECK(joc::oamd::q_of(0, 62) == 0); CHECK(joc::oamd::q_of(62, 62) == 32767); CHECK(joc::oamd::q_of(31, 62) == 16384); CHECK(joc::oamd::q_of(15, 15) == 32767); CHECK(joc::adm::ts(0.0) == "00:00:00.00000"); CHECK(joc::adm::ts(1.5) == "00:00:01.50000"); CHECK(joc::adm::ts(3661.25) == "01:01:01.25000"); // 0.999999 s rounds to the next second, not to 100000 microseconds. CHECK(joc::adm::ts(0.999999) == "00:00:01.00000"); double x = 0.0; double y = 0.0; double z = 0.0; joc::geometry::q_to_adm_xyz(0, 32767, 0, &x, &y, &z); CHECK(x == -1.0 && y == -1.0 && z == 0.0); joc::geometry::q_to_adm_xyz(32767, 0, 0, &x, &y, &z); CHECK(x == 1.0 && y == 1.0 && z == 0.0); } void test_int24_packing() { // Interleaved (L,R) for two frames: 0.0, +1.0, -1.0, +2.0 (clips). const float values[4] = {0.0f, 1.0f, -1.0f, 2.0f}; std::string packed; joc::io::pack_int24(values, 2, 2, &packed); CHECK(packed.size() == 12); auto byte = [&](std::size_t index) { return static_cast(packed[index]); }; CHECK(byte(0) == 0x00 && byte(1) == 0x00 && byte(2) == 0x00); // 0.0 CHECK(byte(3) == 0xFF && byte(4) == 0xFF && byte(5) == 0x7F); // +1.0 CHECK(byte(6) == 0x01 && byte(7) == 0x00 && byte(8) == 0x80); // -1.0 CHECK(byte(9) == 0xFF && byte(10) == 0xFF && byte(11) == 0x7F); // +2.0 clips } void test_utf8_paths() { // Non-ASCII paths must survive the OS boundary on every platform: this is the // regression guard for the ANSI-code-page bug class (a Japanese path used to // reach ffmpeg as mojibake on Windows). const std::string directory = joc::fs_utf8::temp_directory(); CHECK(!directory.empty()); const std::string path = joc::fs_utf8::from_path( joc::fs_utf8::to_path(directory) / joc::fs_utf8::to_path("joc_テスト_須田景凪_测试.bin")); CHECK(joc::fs_utf8::from_path(joc::fs_utf8::to_path(path)) == path); std::FILE* file = joc::fs_utf8::fopen(path, "wb"); CHECK(file != nullptr); if (file != nullptr) { const char payload[] = "joc-utf8"; CHECK(std::fwrite(payload, 1, sizeof(payload) - 1u, file) == sizeof(payload) - 1u); std::fclose(file); } CHECK(joc::fs_utf8::exists(path)); std::error_code error; CHECK(joc::fs_utf8::file_size(path, error) == sizeof("joc-utf8") - 1u); std::ifstream input = joc::fs_utf8::open_input(path); CHECK(input.good()); std::string content(sizeof("joc-utf8") - 1u, '\0'); input.read(content.data(), static_cast(content.size())); CHECK(content == "joc-utf8"); input.close(); std::ofstream output = joc::fs_utf8::open_output(path + ".copy"); CHECK(output.good()); output << content; output.close(); CHECK(joc::fs_utf8::exists(path + ".copy")); CHECK(joc::fs_utf8::remove(path) == 0); CHECK(joc::fs_utf8::remove(path + ".copy") == 0); CHECK(!joc::fs_utf8::exists(path)); } void test_process_runner() { // The ffmpeg boundary must surface both the exit code and the child's stderr. #if defined(_WIN32) const std::vector command = {"cmd.exe", "/c", "echo boom 1>&2 & exit 3"}; #else const std::vector command = {"/bin/sh", "-c", "echo boom 1>&2; exit 3"}; #endif joc::io::ProcessResult result; const joc::Status status = joc::io::run_process(command, &result); CHECK(!status.ok()); CHECK(result.exit_code == 3u); CHECK(result.output.find("boom") != std::string::npos); } void test_npy_and_zip_writers() { using joc::io::NpyMember; // Python's repr, which is what json.dumps writes into the cache metadata. CHECK(joc::io::python_float_repr(48000.0) == "48000.0"); CHECK(joc::io::python_float_repr(1.0) == "1.0"); CHECK(joc::io::python_float_repr(0.001) == "0.001"); CHECK(joc::io::python_float_repr(1.0e-5) == "1e-05"); CHECK(joc::io::python_float_repr(1.5) == "1.5"); CHECK(joc::io::python_float_repr(-2.25) == "-2.25"); CHECK(joc::io::python_float_repr(1.0e16) == "1e+16"); CHECK(joc::io::python_float_repr(1234567890123456.0) == "1234567890123456.0"); // An NPY image must round-trip through the reader that loads the cache. const std::vector values = {1.5, -2.25, 3.75, 4.0, 0.5, 0.25}; std::vector payload(values.size() * sizeof(double)); std::memcpy(payload.data(), values.data(), payload.size()); const std::vector image = joc::io::npy_image(" restored; CHECK(joc::io::npy_to_double(array, &restored, &error)); CHECK(restored.size() == values.size()); bool same = restored.size() == values.size(); for (std::size_t index = 0; index < restored.size() && same; ++index) { same = restored[index] == values[index]; } CHECK(same); // A Unicode scalar string member, as the cache stores its metadata. const std::string text = "{\"k\":\"v\"}"; const std::vector unicode = joc::io::utf8_to_utf32le(text); CHECK(unicode.size() == text.size() * 4u); CHECK(unicode[0] == static_cast('{') && unicode[1] == 0u && unicode[3] == 0u); const std::vector text_image = joc::io::npy_image(" members; NpyMember member; member.name = "band_center_frequencies_hz"; member.descr = " read_back; CHECK(archive.read_member("band_center_frequencies_hz.npy", &read_back, &error)); CHECK(read_back.size() == image.size()); CHECK(read_back.size() == image.size() && std::memcmp(read_back.data(), image.data(), image.size()) == 0); CHECK(joc::fs_utf8::remove(path) == 0); } // --------------------------------------------------------------------------- // HRTF import and compile path. // // Everything below builds its input in memory, so the suite reads no external // files; the parts that need one are exercised through their error paths. // --------------------------------------------------------------------------- constexpr double kPi = 3.14159265358979323846; // A SimpleFreeFieldHRIR set with `measurements` directions on one 1 m shell and // `taps` decaying taps per ear. The values are closed-form placeholders; what // the canonicaliser and the spherical-harmonic fit actually read is the // geometry and the shape. joc::hrtf::SofaHrir synthetic_sofa(std::uint32_t measurements, std::uint32_t taps) { joc::hrtf::SofaHrir sofa; sofa.sample_rate = 48000.0; sofa.sampling_rate_units = "hertz"; sofa.ir_count = measurements; sofa.ir_length = taps; sofa.conventions = "SOFA"; sofa.sofa_conventions = "SimpleFreeFieldHRIR"; sofa.convention_version = "1.0"; sofa.version = "1.0"; sofa.data_type = "FIR"; sofa.room_type = "free field"; sofa.receiver_position[0] = 0.0; sofa.receiver_position[1] = 0.09; sofa.receiver_position[2] = 0.0; sofa.receiver_position[3] = 0.0; sofa.receiver_position[4] = -0.09; sofa.receiver_position[5] = 0.0; sofa.source_position_coordinates.type = "spherical"; sofa.source_position_coordinates.units = "degree, degree, metre"; sofa.emitter_position_coordinates.type = "cartesian"; sofa.emitter_position_coordinates.units = "metre"; sofa.listener_position_coordinates.type = "cartesian"; sofa.listener_position_coordinates.units = "metre"; sofa.listener_view_coordinates.type = "cartesian"; sofa.listener_view_coordinates.units = "metre"; sofa.receiver_position_coordinates.type = "cartesian"; sofa.receiver_position_coordinates.units = "metre"; sofa.source_sha256 = std::string(64u, '0'); sofa.source_position.assign(static_cast(measurements) * 3u, 0.0); sofa.ir.assign(static_cast(measurements) * 2u * taps, 0.0); for (std::uint32_t index = 0; index < measurements; ++index) { // Fibonacci sphere: an even spread over the whole sphere, every point at // one metre, so the set is a single shell. const double unit = (static_cast(index) + 0.5) / static_cast(measurements); const double elevation = std::asin(2.0 * unit - 1.0); const double azimuth = 2.0 * kPi * std::fmod(static_cast(index) * 0.6180339887498949, 1.0); sofa.source_position[index * 3u + 0u] = azimuth * 180.0 / kPi; sofa.source_position[index * 3u + 1u] = elevation * 180.0 / kPi; sofa.source_position[index * 3u + 2u] = 1.0; for (std::uint32_t ear = 0; ear < 2u; ++ear) { for (std::uint32_t tap = 0; tap < taps; ++tap) { const double decay = std::exp(-4.0 * static_cast(tap) / static_cast(taps)); const double phase = 0.2 * static_cast(tap) + 0.05 * static_cast(index) + 1.5 * static_cast(ear) + elevation; sofa.ir[(static_cast(index) * 2u + ear) * taps + tap] = decay * std::sin(phase); } } } return sofa; } void test_sofa_canonicalize() { const joc::hrtf::SofaHrir sofa = synthetic_sofa(64u, 64u); joc::hrtf::CanonicalHrtf canonical; const joc::Status status = joc::hrtf::canonicalize_sofa(sofa, &canonical); CHECK(status.ok()); if (!status.ok()) { std::printf(" canonicalize_sofa failed: %s\n", status.message().c_str()); return; } CHECK(canonical.measurements == 64u && canonical.taps == 64u); CHECK(canonical.sample_rate_hz == 48000.0); CHECK(canonical.hrir.size() == 64u * 2u * 64u); CHECK(canonical.delay_samples.size() == 64u * 2u); CHECK(canonical.unit_directions.size() == 64u * 3u); CHECK(canonical.source_position_cartesian_m.size() == 64u * 3u); CHECK(canonical.left_receiver_index != canonical.right_receiver_index); CHECK(canonical.left_receiver_index >= 0 && canonical.left_receiver_index < 2); CHECK(canonical.right_receiver_index >= 0 && canonical.right_receiver_index < 2); bool unit_length = canonical.unit_directions.size() == 64u * 3u; bool one_shell = canonical.measurement_radius_m.size() == 64u; for (std::size_t index = 0u; index < 64u; ++index) { const double x = canonical.unit_directions[index * 3u + 0u]; const double y = canonical.unit_directions[index * 3u + 1u]; const double z = canonical.unit_directions[index * 3u + 2u]; unit_length = unit_length && std::abs(x * x + y * y + z * z - 1.0) < 1.0e-9; one_shell = one_shell && std::abs(canonical.measurement_radius_m[index] - 1.0) < 1.0e-9; } CHECK(unit_length); CHECK(one_shell); // The shell the compile step selects, and the radius it reports for it. double actual_radius = 0.0; const std::vector shell = joc::hrtf::canonical_shell_indices(canonical, 0.5, &actual_radius); CHECK(shell.size() == 64u); CHECK(std::abs(actual_radius - 1.0) < 1.0e-9); // The import is strict: each of these is a rejection, never a silent repair. joc::hrtf::CanonicalHrtf rejected; joc::hrtf::SofaHrir broken = sofa; broken.conventions = "not-sofa"; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.sofa_conventions = "SimpleFreeFieldHRTF"; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.convention_version = "9.9"; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.data_type = "FLOAT"; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.room_type = ""; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.sampling_rate_units = "furlongs"; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.sample_rate = 0.0; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.ir[0] = std::numeric_limits::quiet_NaN(); CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.delay[1] = -1.0; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.emitter_position[0] = 0.5; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.listener_view[0] = broken.listener_view[1] = broken.listener_view[2] = 0.0; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.listener_up[0] = 1.0; // parallel to ListenerView broken.listener_up[1] = 0.0; broken.listener_up[2] = 0.0; CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.source_position[2] = -1.0; // a negative spherical radius CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); broken = sofa; broken.ir.resize(7u); CHECK(!joc::hrtf::canonicalize_sofa(broken, &rejected).ok()); } void test_sofa_compile_and_cache() { const joc::hrtf::SofaHrir sofa = synthetic_sofa(64u, 64u); const joc::hrtf::CompileOptions options = joc::hrtf::CompileOptions(); joc::hrtf::Field field; // The runtime field is fixed at fifth order and at 48 kHz. joc::hrtf::CompileOptions wrong_order = options; wrong_order.order = 3; CHECK(!joc::hrtf::compile_sofa_field(sofa, wrong_order, &field).ok()); joc::hrtf::SofaHrir wrong_rate = sofa; wrong_rate.sample_rate = 44100.0; CHECK(!joc::hrtf::compile_sofa_field(wrong_rate, options, &field).ok()); const joc::Status status = joc::hrtf::compile_sofa_field(sofa, options, &field); CHECK(status.ok()); if (!status.ok()) { std::printf(" compile_sofa_field failed: %s\n", status.message().c_str()); return; } CHECK(field.order == 5); CHECK(field.measurement_radius_m == 1.0); CHECK(field.band_centers_hz.size() == 77u); CHECK(field.coefficients.size() == 36u * 2u * 77u * 2u); CHECK(field.delay_coefficients.size() == 36u * 2u); CHECK(field.delay_bounds.size() == 4u); CHECK(!field.cache_key.empty() && !field.payload_sha256.empty()); CHECK(!field.metadata_json.empty() && !field.delay_source.empty()); bool finite = true; for (const double value : field.coefficients) { finite = finite && std::isfinite(value); } for (const double value : field.delay_coefficients) { finite = finite && std::isfinite(value); } CHECK(finite); // The payload digest is a pure function of the compiled arrays. CHECK(joc::hrtf::field_payload_sha256(field) == field.payload_sha256); // The cache round-trips through the shipped writer and loader. const std::string cache_path = joc::fs_utf8::temp_directory() + "/joc_test_cache.jochrtf"; CHECK(joc::hrtf::write_jochrtf(field, cache_path).ok()); joc::hrtf::Field reloaded; const joc::Status loaded = joc::hrtf::load_jochrtf(cache_path, &reloaded); CHECK(loaded.ok()); if (loaded.ok()) { CHECK(reloaded.cache_key == field.cache_key); CHECK(reloaded.source_sha256 == field.source_sha256); CHECK(reloaded.order == field.order); CHECK(reloaded.measurement_radius_m == field.measurement_radius_m); CHECK(reloaded.band_centers_hz == field.band_centers_hz); CHECK(reloaded.coefficients == field.coefficients); CHECK(reloaded.delay_coefficients == field.delay_coefficients); CHECK(reloaded.delay_bounds == field.delay_bounds); CHECK(reloaded.payload_sha256 == field.payload_sha256); } // A cache is trusted only after its key and payload verify; a damaged one is // rejected instead of being used. CHECK(joc::hrtf::validate_jochrtf(cache_path, field.source_sha256, field.cache_key, nullptr) .ok()); CHECK(!joc::hrtf::validate_jochrtf(cache_path, field.source_sha256, "00", nullptr).ok()); CHECK(joc::fs_utf8::remove(cache_path) == 0); const std::string poisoned = joc::fs_utf8::temp_directory() + "/joc_test_poisoned.jochrtf"; CHECK(joc::io::write_zip(poisoned, {}, nullptr)); CHECK(!joc::hrtf::validate_jochrtf(poisoned, field.source_sha256, field.cache_key, nullptr) .ok()); CHECK(!joc::hrtf::load_jochrtf(poisoned, &reloaded).ok()); CHECK(joc::fs_utf8::remove(poisoned) == 0); } void test_sofa_reader_errors() { // No SOFA file is needed to check that the reader answers correctly: a path // that does not exist and a file that is not HDF5 are distinct, reported // errors rather than a partially filled structure. joc::hrtf::SofaHrir sofa; const std::string missing = joc::fs_utf8::temp_directory() + "/joc_absent.sofa"; const joc::Status absent = joc::hrtf::load_sofa(missing, &sofa); CHECK(!absent.ok()); CHECK(!absent.message().empty()); CHECK(!joc::hrtf::load_sofa(std::string(), &sofa).ok()); CHECK(!joc::hrtf::load_sofa(missing, nullptr).ok()); const std::string garbage = joc::fs_utf8::temp_directory() + "/joc_garbage.sofa"; { std::ofstream stream = joc::fs_utf8::open_output(garbage); stream << "this is not an HDF5 container"; } const joc::Status malformed = joc::hrtf::load_sofa(garbage, &sofa); CHECK(!malformed.ok()); CHECK(!malformed.message().empty()); CHECK(joc::fs_utf8::remove(garbage) == 0); } void test_hrtf_cache_policy() { // The policy parser and the miss path of the cache layer, neither of which // needs a SOFA file. joc::hrtf::CachePolicy policy = joc::hrtf::CachePolicy::None; CHECK(joc::hrtf::parse_cache_policy("none", &policy).ok() && policy == joc::hrtf::CachePolicy::None); CHECK(joc::hrtf::parse_cache_policy("memory", &policy).ok() && policy == joc::hrtf::CachePolicy::Memory); CHECK(joc::hrtf::parse_cache_policy("disk", &policy).ok() && policy == joc::hrtf::CachePolicy::Disk); CHECK(!joc::hrtf::parse_cache_policy("sometimes", &policy).ok()); joc::hrtf::SofaFieldRequest request; request.sofa_path = joc::fs_utf8::temp_directory() + "/joc_absent.sofa"; request.policy = joc::hrtf::CachePolicy::Disk; request.cache_dir = joc::fs_utf8::temp_directory() + "/joc_absent_cache"; joc::hrtf::Field field; std::string written = "sentinel"; CHECK(!joc::hrtf::load_or_compile_sofa_field(request, &field, &written).ok()); } void test_rosella_model_errors() { // A real model is proprietary user data and is not part of this repository. // What can be checked without one is that every malformed input is reported // instead of being half-loaded. joc::hrtf::RosellaModel model; const std::string missing = joc::fs_utf8::temp_directory() + "/joc_absent.headphone"; CHECK(!joc::hrtf::load_personalized_headphone(missing, &model).ok()); CHECK(!joc::hrtf::load_personalized_headphone(missing, nullptr).ok()); const std::string raw = joc::fs_utf8::temp_directory() + "/joc_raw.headphone"; { std::ofstream stream = joc::fs_utf8::open_output(raw); stream << "rp binary model, not JSON"; } const joc::Status raw_status = joc::hrtf::load_personalized_headphone(raw, &model); CHECK(!raw_status.ok()); CHECK(raw_status.message().find("JSON") != std::string::npos); CHECK(joc::fs_utf8::remove(raw) == 0); const std::string empty = joc::fs_utf8::temp_directory() + "/joc_empty.headphone"; { std::ofstream stream = joc::fs_utf8::open_output(empty); stream << "{}"; } CHECK(!joc::hrtf::load_personalized_headphone(empty, &model).ok()); CHECK(joc::fs_utf8::remove(empty) == 0); const std::string no_coefficients = joc::fs_utf8::temp_directory() + "/joc_no_coeff.headphone"; { std::ofstream stream = joc::fs_utf8::open_output(no_coefficients); stream << "{\"personalized_hrtf\":{\"virtualizer_parameters\":{}}}"; } const joc::Status status = joc::hrtf::load_personalized_headphone(no_coefficients, &model); CHECK(!status.ok()); CHECK(status.message().find("rosella_coefficients") != std::string::npos); CHECK(joc::fs_utf8::remove(no_coefficients) == 0); } void test_builtin_kernel_tables() { // The filterbank tables are compiled in, and their content must stay identical // to the standard-defined archive the file loader accepts. The hashes below are // the SHA-256 of each member's C-order bytes. const joc::hrtf::Kernels& kernels = joc::hrtf::builtin_kernels(); CHECK(kernels.qmf_analysis.size() == 640); CHECK(kernels.hybrid_low.size() == 2496); CHECK(kernels.hybrid_indices.size() == 616); CHECK(kernels.hybrid_values.size() == 154); CHECK(kernels.qmf_basis.size() == 32768); CHECK(kernels.qmf_taps.size() == 2560); CHECK(kernels.hybrid_count == 154); auto hash_floats = [](const std::vector& values, bool narrow) { joc::crypto::Sha256 hash; if (narrow) { std::vector raw(values.size()); for (std::size_t i = 0; i < values.size(); ++i) { raw[i] = static_cast(values[i]); } hash.update(raw.data(), raw.size() * sizeof(float)); } else { hash.update(values.data(), values.size() * sizeof(double)); } return hash.finish_hex(); }; CHECK(hash_floats(kernels.qmf_analysis, true) == "aeff6c7117d41664b9c4bf03bbf563f5319ec1b8c551f171adbb90cf19d9d306"); CHECK(hash_floats(kernels.hybrid_low, true) == "d00d36133b81ba699a7630c4df1be203fa1b7e371e595eaaebbe8957db322627"); CHECK(hash_floats(kernels.hybrid_values, true) == "99409fdd9d20d1d7c2be16bbc1e2159c8042487227c72160850745164c9cee7f"); CHECK(hash_floats(kernels.qmf_basis, false) == "a0c4a55385f6d6c7c92d7615c83ad5fbda51046d9ef785cac0b9ac9a760dc527"); CHECK(hash_floats(kernels.qmf_taps, false) == "cd7756d060d51fbf02f44c1ce53cb6225b221099505c94c3f58d3bee6f428150"); joc::crypto::Sha256 indices; indices.update(kernels.hybrid_indices.data(), kernels.hybrid_indices.size() * sizeof(std::int16_t)); CHECK(indices.finish_hex() == "f5beb3220e4530fcf28e7f4da7f07e821074265d118c911d61a590e00753a573"); } } // namespace int main() { test_sha256(); test_crc32_and_inflate(); test_bit_reader(); test_variable_bits(); test_eac3_frame_bytes(); test_mini_json(); test_npy(); test_oamd_and_adm_helpers(); test_int24_packing(); test_utf8_paths(); test_process_runner(); test_builtin_kernel_tables(); test_npy_and_zip_writers(); test_sofa_canonicalize(); test_sofa_compile_and_cache(); test_sofa_reader_errors(); test_hrtf_cache_policy(); test_rosella_model_errors(); std::printf("%d checks, %d failure(s)\n", g_checks, g_failures); return g_failures == 0 ? 0 : 1; }