from __future__ import annotations from dataclasses import replace import contextlib import hashlib import io import json import math from pathlib import Path import struct import sys import tempfile import unittest import zipfile import h5py import numpy as np ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) import main import sofa_canonical from adm_atmos import Sink25, q_to_adm_xyz from binaural_metadata import OamdPositionTimeline from binaural_renderer import ( SofaBinauralRenderer, resolve_compiled_hrtf_cache, resolve_sofa_hrtf, ) from oamd_bits import q_of from public_filterbank import ( ANALYSIS_SYNTHESIS_LATENCY_SAMPLES, HybridAnalysis, HybridSynthesis, PublicAnalysis77, PublicSynthesis77, QmfAnalysis, QmfSynthesis, filterbank_fingerprint, hybrid_band_center_frequencies_hz, hybrid_gain_synthesis_dictionary, identity_impulse_response, load_filterbank_tables, ) from public_room import ( LateFdnConfig, SharedUnitaryFdn, ShoeboxRoomConfig, first_order_image_sources, ) from reference_distance import ReferenceDistanceProfileV1 from sofa_binaural_backend import SofaBinauralBackend from sofa_canonical import SofaImportError, load_simple_free_field_hrir, time_align_hrtf from sofa_hrtf_field import ( HrtfCacheError, JOCHRTF_FORMAT_VERSION, JOCHRTF_MAGIC, SofaHrtfField, compile_sofa_hrtf, compiled_hrtf_cache_key, ) from speaker_wav import BinauralPcmSpool, write_pcm_wav class NativeSofaBackendTest(unittest.TestCase): def test_native_sofa_backend_matches_python_reference(self): try: from sofa_native_backend import NativeSofaBinauralDsp except (ImportError, OSError, RuntimeError) as exc: self.skipTest(f"native SOFA backend unavailable: {exc}") with tempfile.TemporaryDirectory() as directory: root = Path(directory) sofa = _write_sofa(root / "subject.sofa") field = compile_sofa_hrtf(sofa, cache_policy="none") python = SofaBinauralBackend(field, source_count=16, default_profile="mid") native = NativeSofaBinauralDsp(field, source_count=16, default_profile="mid") try: rng = np.random.default_rng(20260906) blocks = [rng.standard_normal((512, 16)) * 0.01 for _ in range(4)] blocks[0][0, 1] = 1.0 positions = [ np.asarray([0.25 * index - 1.5, -0.5, 0.1 * (index % 3)]) for index in range(15)] py_out = [] nat_out = [] for block in blocks: for backend in (python, native): backend.set_source( 0, (0.0, 1.0, 0.0), profile="mid", special_lfe=True) for index, position in enumerate(positions): backend.set_source(index + 1, position, profile="mid") py_out.append(python.process(block)) nat_out.append(native.process(block)) expected = np.concatenate(py_out) actual = np.concatenate(nat_out) self.assertEqual(actual.shape, expected.shape) self.assertEqual(actual.dtype, np.float64) self.assertTrue(np.isfinite(actual).all()) np.testing.assert_allclose( actual, expected, rtol=1.0e-9, atol=1.0e-9) finally: native.close() class AdmBwfRf64Test(unittest.TestCase): def test_adm_bwf_over_4gb_writes_rf64_sentinel_sizes(self): # 25ch 24bit 数据超过 0xFFFFFFFF 字节时:data chunk 的 32-bit size # 字段按 RF64 写 0xFFFFFFFF,真实 riff/data/sample 大小回填 ds64, # 不能抛 struct.error。 with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "big.wav" sink = Sink25(path, 25, 48000) sink.write_block(np.zeros((64, 25), dtype=np.float32)) sink.frames = (0x1_0000_0000 // (25 * 3)) + 1 data_len = sink.frames * 25 * 3 self.assertGreater(data_len, 0xFFFFFFFF) sink.finalize(b"", b"chna", b"dbmd") with path.open("rb") as stream: head = stream.read(80) stream.seek(0, 2) total = stream.tell() self.assertEqual(head[:4], b"RF64") self.assertEqual(head[4:8], b"\xff\xff\xff\xff") self.assertEqual(head[8:12], b"WAVE") self.assertEqual(head[12:16], b"ds64") self.assertEqual(struct.unpack("", b"chna", b"dbmd") with path.open("rb") as stream: head = stream.read(80) stream.seek(0, 2) total = stream.tell() data_len = 64 * 25 * 3 riff_size, ds_data, samples, _table = struct.unpack( " np.ndarray: index = np.arange(count, dtype=np.float64) z = 1.0 - 2.0 * (index + 0.5) / count azimuth = index * (math.pi * (3.0 - math.sqrt(5.0))) radius = np.sqrt(np.maximum(0.0, 1.0 - z * z)) return np.stack((radius * np.cos(azimuth), radius * np.sin(azimuth), z), axis=1) def _write_sofa(path: Path, *, measurements: int = 48, taps: int = 64, sample_rate: float = 48000.0, data_delay=None, reverse_receivers: bool = False) -> Path: directions = _fibonacci_directions(measurements) hrir = np.zeros((measurements, 2, taps), dtype=np.float64) left = 0.7 + 0.2 * directions[:, 1] right = 0.7 - 0.2 * directions[:, 1] hrir[:, 0, 0] = left hrir[:, 1, 0] = right if reverse_receivers: hrir = hrir[:, ::-1] delay = (np.zeros((1, 2), dtype=np.float64) if data_delay is None else np.asarray(data_delay, dtype=np.float64)) with h5py.File(path, "w") as file: file.attrs["Conventions"] = np.bytes_("SOFA") file.attrs["Version"] = np.bytes_("1.0") file.attrs["SOFAConventions"] = np.bytes_("SimpleFreeFieldHRIR") file.attrs["SOFAConventionsVersion"] = np.bytes_("1.0") file.attrs["DataType"] = np.bytes_("FIR") file.attrs["RoomType"] = np.bytes_("free field") file.attrs["Title"] = np.bytes_("production synthetic fixture") file.create_dataset("Data.IR", data=hrir) rate = file.create_dataset("Data.SamplingRate", data=[sample_rate]) rate.attrs["Units"] = np.bytes_("hertz") file.create_dataset("Data.Delay", data=delay) listener = file.create_dataset("ListenerPosition", data=[[0.0, 0.0, 0.0]]) listener.attrs["Type"] = np.bytes_("cartesian") listener.attrs["Units"] = np.bytes_("metre") view = file.create_dataset("ListenerView", data=[[1.0, 0.0, 0.0]]) view.attrs["Type"] = np.bytes_("cartesian") view.attrs["Units"] = np.bytes_("metre") up = file.create_dataset("ListenerUp", data=[[0.0, 0.0, 1.0]]) up.attrs["Type"] = np.bytes_("cartesian") up.attrs["Units"] = np.bytes_("metre") source = file.create_dataset("SourcePosition", data=directions) source.attrs["Type"] = np.bytes_("cartesian") source.attrs["Units"] = np.bytes_("metre") receivers = np.zeros((2, 3, 1), dtype=np.float64) receivers[0, 1, 0] = 0.0875 receivers[1, 1, 0] = -0.0875 if reverse_receivers: receivers = receivers[::-1] receiver = file.create_dataset("ReceiverPosition", data=receivers) receiver.attrs["Type"] = np.bytes_("cartesian") receiver.attrs["Units"] = np.bytes_("metre") emitter = file.create_dataset("EmitterPosition", data=np.zeros((1, 3, 1))) emitter.attrs["Type"] = np.bytes_("cartesian") emitter.attrs["Units"] = np.bytes_("metre") return path def _constant_field() -> SofaHrtfField: source_sha256 = "A" * 64 projection_ridge = 1.0e-3 sh_ridge = 1.0e-5 cache_key = compiled_hrtf_cache_key( source_sha256=source_sha256, sample_rate_hz=48000.0, shell_radius_m=1.0, order=5, projection_ridge=projection_ridge, sh_ridge=sh_ridge) coefficients = np.zeros((36, 2, 77), dtype=np.complex128) coefficients[0] = math.sqrt(4.0 * math.pi) return SofaHrtfField( source_sha256=source_sha256, source_display_name="synthetic.sofa", sample_rate_hz=48000.0, measurement_radius_m=1.0, order=5, projection_ridge=projection_ridge, spherical_harmonic_ridge=sh_ridge, band_center_frequencies_hz=hybrid_band_center_frequencies_hz(), coefficients=coefficients, delay_coefficients=np.zeros((36, 2), dtype=np.float64), delay_bounds=np.zeros((2, 2), dtype=np.float64), delay_source="zero-origin embedded phase", fit_report={"fixture": True}, cache_key=cache_key) def _constant_field_with_delays(left: float, right: float) -> SofaHrtfField: delays = np.asarray([left, right], dtype=np.float64) coefficients = np.zeros((36, 2), dtype=np.float64) coefficients[0] = delays * math.sqrt(4.0 * math.pi) return replace( _constant_field(), delay_coefficients=coefficients, delay_bounds=np.stack((delays, delays), axis=1), delay_source="synthetic external Data.Delay") def _render_delayed_field_impulse(left: float, right: float, *, path_delay: float = 0.0): backend = SofaBinauralBackend( _constant_field_with_delays(left, right), source_count=1, default_profile="near", enable_early_reflections=False, enable_late_room=False) if path_delay: path = backend._make_path( "synthetic_path", (0.0, 1.0, 0.0), 1.0, path_delay, 1.0) backend.paths.set_paths(0, (path,), fade_slots=0) backend.maximum_early_delay_samples = float(path_delay) source = np.zeros((512, 1), dtype=np.float64) source[0, 0] = 1.0 output = np.concatenate(( backend.process(source), backend.finish(tail_seconds=0.0)), axis=0) return backend, output class _RecordingBackend(SofaBinauralBackend): def __init__(self): self.source_count = 16 self.default_profile = "mid" self.calls = [] self.process_calls = [] self.finish_calls = 0 def set_source(self, source, position_adm, **options): self.calls.append((int(source), tuple(np.asarray(position_adm)), dict(options))) def process(self, values): self.process_calls.append(np.asarray(values).copy()) return np.zeros((len(values), 2), dtype=np.float64) def finish(self, *, tail_seconds=None): self.finish_calls += 1 self.finish_tail_seconds = tail_seconds return np.zeros((64, 2), dtype=np.float64) def finish_output_capacity(self, tail_seconds=None): del tail_seconds return 64 def info(self): return { "name": "recording", "hrtf_input_kind": "field", "hrtf_input_path": None, "cache_policy": None, "field": _constant_field().info(), } class BinauralProductionTest(unittest.TestCase): def test_cli_uses_explicit_sofa_or_compiled_cache_inputs(self): parser = main.build_parser() args = parser.parse_args([ "input.eac3", "--binaural", "--sofa-hrtf", "subject.sofa"]) self.assertEqual(args.binaural_mode, "mid") self.assertEqual(args.binaural_format, "float32") self.assertEqual(args.sofa_hrtf, Path("subject.sofa")) self.assertIsNone(args.hrtf_cache_policy) self.assertIsNone(args.personalized_headphone) help_text = parser.format_help() self.assertIn("--personalized-headphone", help_text) def test_hrtf_resolvers_enforce_explicit_extensions(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) sofa = _write_sofa(root / "subject.sofa") cache = root / "subject.jochrtf" _constant_field().save(cache) self.assertEqual(resolve_sofa_hrtf(sofa), sofa.resolve()) self.assertEqual(resolve_compiled_hrtf_cache(cache), cache.resolve()) with self.assertRaisesRegex(ValueError, r"\.sofa"): resolve_sofa_hrtf(cache) with self.assertRaisesRegex(ValueError, r"\.jochrtf"): resolve_compiled_hrtf_cache(sofa) def test_cli_hrtf_input_policy_validation(self): parser = main.build_parser() with tempfile.TemporaryDirectory() as directory: root = Path(directory) sofa = _write_sofa(root / "subject.sofa") cache = root / "subject.jochrtf" _constant_field().save(cache) args = parser.parse_args([ "input.eac3", "--binaural", "--sofa-hrtf", str(sofa)]) resolved = main.resolve_binaural_hrtf_input(args, required=True) self.assertEqual(resolved["kind"], "sofa") self.assertEqual(resolved["cache_policy"], "memory") missing = parser.parse_args(["input.eac3", "--binaural"]) # 把默认查找位置指到临时目录,避免工作区里真实存在的 # HRTF/binaural.sofa 或 output/hrtf-cache 影响该用例。 with unittest.mock.patch.object( main, "DEFAULT_SOFA_HRTF", root / "HRTF" / "binaural.sofa"), \ unittest.mock.patch.object( main, "DEFAULT_PERSONALIZED_HEADPHONE", root / "HRTF" / "binaural.personalized_headphone"), \ unittest.mock.patch.object( main, "DEFAULT_HRTF_CACHE_DIR", root / "cache"): with self.assertRaisesRegex(ValueError, "--sofa-hrtf"): main.resolve_binaural_hrtf_input(missing, required=True) compiled = parser.parse_args([ "input.eac3", "--binaural", "--compiled-hrtf-cache", str(cache), "--hrtf-cache-policy", "memory"]) with self.assertRaisesRegex(ValueError, "不能再指定"): main.resolve_binaural_hrtf_input(compiled, required=True) def test_binaural_cli_defaults_mode_to_mid(self): class _FakeDecoder: dsp_backend = "python-sofa" finish_capacity_samples = 2048 def render_frame(self, *args, **kwargs): return np.empty((0, 2), dtype=np.float64) def finish(self): return np.empty((0, 2), dtype=np.float64) def close(self): pass @property def backend_info(self): return { "hrtf_input_kind": "sofa", "hrtf_input_path": "fake.sofa", "cache_policy": "memory", "field": {"source_sha256": "f" * 64, "cache_key": "k", "format_version": 1}, } def fake_render(index, bed_path, frame_count, raw_path, gain, progress_every, backend=None, native_library=None, native_threads=None, frame_sink=None, speaker_renderer=None, speaker_sink=None, speaker_metadata_offset=1473, binaural_renderer=None, binaural_sink=None, binaural_metadata_offset=1473, raw_scale=1.0): binaural_sink.write_frame(np.zeros((1536, 2), dtype=np.float64)) return (0.0, {"name": "test"}, {"pipeline_wall_seconds": 0.0, "binaural_render_seconds": 0.0}) index = unittest.mock.MagicMock(rows=[{}]) index.__len__.return_value = 1 with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "input.eac3" source.touch() sofa = _write_sofa(root / "subject.sofa") output = root / "out.binaural.wav" argv = [str(source), "-o", str(output), "--skip-sha256", "--binaural", "--backend", "python", "--sofa-hrtf", str(sofa)] with contextlib.redirect_stdout(io.StringIO()), \ unittest.mock.patch.object(main, "executable", return_value="unused"), \ unittest.mock.patch.object(main, "extract_eac3", return_value=source), \ unittest.mock.patch.object(main, "decode_core", return_value=source), \ unittest.mock.patch.object( main, "resolve_metadata", return_value=(index, "test", None)), \ unittest.mock.patch.object(main, "render", side_effect=fake_render), \ unittest.mock.patch.object( main.SofaBinauralRenderer, "from_sofa", classmethod(lambda cls, *args, **kwargs: _FakeDecoder())): self.assertEqual(main.main(argv), 0) report = json.loads( Path(str(output) + ".report.json").read_text(encoding="utf-8")) self.assertEqual(report["binaural_mode"], "mid") self.assertIsNone(report["binaural_mode_value"]) def test_binaural_rejects_off_mode(self): with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "input.eac3" source.write_bytes(b"fixture") with self.assertRaisesRegex(ValueError, "--binaural-mode off"): main.main([ str(source), "--binaural", "--sofa-hrtf", "unused.sofa", "--binaural-mode", "off"]) def test_cli_rejects_nonfinite_binaural_tail_options(self): with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "input.eac3" source.write_bytes(b"fixture") for option, value in ( ("--binaural-tail-seconds", "nan"), ("--binaural-tail-seconds", "inf"), ("--binaural-tail-threshold", "nan"), ("--binaural-tail-threshold", "inf")): with self.subTest(option=option, value=value): with self.assertRaisesRegex(ValueError, "有限值"): main.main([ str(source), "--binaural", "--sofa-hrtf", "unused.sofa", option, value]) def test_strict_importer_reorders_receivers_and_keeps_delay_external(self): with tempfile.TemporaryDirectory() as directory: path = _write_sofa( Path(directory) / "reversed.sofa", data_delay=[[2.5, 1.25]], reverse_receivers=True) canonical = load_simple_free_field_hrir(path) self.assertEqual((canonical.left_receiver_index, canonical.right_receiver_index), (1, 0)) np.testing.assert_array_equal( canonical.delay_samples[0], [1.25, 2.5]) aligned = time_align_hrtf(canonical) np.testing.assert_array_equal(aligned.aligned_hrir, canonical.hrir) np.testing.assert_array_equal( aligned.runtime_delay_samples, canonical.delay_samples) self.assertEqual(np.count_nonzero( aligned.embedded_delay_removed_samples), 0) def test_importer_resamples_fir_and_scales_external_delay_samples(self): with tempfile.TemporaryDirectory() as directory: path = _write_sofa( Path(directory) / "resample.sofa", sample_rate=24000.0, data_delay=[[2.5, 1.25]]) canonical = load_simple_free_field_hrir( path, target_sample_rate_hz=48000.0) self.assertEqual(canonical.hrir.shape, (48, 2, 128)) np.testing.assert_allclose(canonical.delay_samples[0], [5.0, 2.5]) def test_importer_rejects_a_source_changed_during_the_stable_read(self): with tempfile.TemporaryDirectory() as directory: path = _write_sofa(Path(directory) / "changing.sofa") original_hash = sofa_canonical._sha256_stream calls = 0 def mutate_before_confirmation(stream): nonlocal calls calls += 1 if calls == 2: with path.open("r+b") as writer: writer.seek(-1, 2) final_byte = writer.read(1) writer.seek(-1, 2) writer.write(bytes([final_byte[0] ^ 1])) return original_hash(stream) sofa_canonical._sha256_stream = mutate_before_confirmation try: with self.assertRaisesRegex(SofaImportError, "changed while"): load_simple_free_field_hrir(path) finally: sofa_canonical._sha256_stream = original_hash self.assertEqual(calls, 2) def test_zero_origin_and_positive_onset_delay_paths_are_exclusive(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) zero = load_simple_free_field_hrir(_write_sofa(root / "zero.sofa")) zero_alignment = time_align_hrtf(zero) np.testing.assert_array_equal( zero_alignment.runtime_delay_samples, np.zeros((48, 2))) onset_path = _write_sofa(root / "onset.sofa") with h5py.File(onset_path, "r+") as file: hrir = file["Data.IR"] values = np.zeros_like(hrir[...]) values[:, 0, 5] = 0.8 values[:, 1, 8] = 0.7 hrir[...] = values onset = load_simple_free_field_hrir(onset_path) onset_alignment = time_align_hrtf(onset) np.testing.assert_allclose( onset_alignment.runtime_delay_samples, np.tile([5.0, 8.0], (48, 1))) np.testing.assert_allclose( onset_alignment.embedded_delay_removed_samples, onset_alignment.runtime_delay_samples) def test_importer_rejects_non_free_field_convention(self): with tempfile.TemporaryDirectory() as directory: path = _write_sofa(Path(directory) / "wrong.sofa") with h5py.File(path, "r+") as file: file.attrs["SOFAConventions"] = np.bytes_("GeneralFIR") with self.assertRaisesRegex(SofaImportError, "unsupported SOFAConventions"): load_simple_free_field_hrir(path) def test_importer_keeps_close_radius_shells_distinct_and_rejects_negative_radius(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) path = _write_sofa(root / "shells.sofa") with h5py.File(path, "r+") as file: values = file["SourcePosition"][...] values[24:] *= 1.000009 file["SourcePosition"][...] = values canonical = load_simple_free_field_hrir(path) self.assertEqual(len(canonical.shell_indices(1.0)), 24) self.assertEqual(len(canonical.shell_indices(1.000009)), 24) query = np.asarray([2.0, 0.0, 0.0]) canonical.nearest_index(query) np.testing.assert_array_equal(query, [2.0, 0.0, 0.0]) invalid = _write_sofa(root / "negative-radius.sofa") with h5py.File(invalid, "r+") as file: source = file["SourcePosition"] source[...] = np.tile([0.0, 0.0, -1.0], (48, 1)) source.attrs["Type"] = np.bytes_("spherical") source.attrs["Units"] = np.bytes_("degree, degree, metre") with self.assertRaisesRegex(SofaImportError, "negative spherical radius"): load_simple_free_field_hrir(invalid) def test_filterbank_identity_latency_and_precision(self): qmf = QmfAnalysis(1) hybrid = HybridAnalysis(1) hybrid_synthesis = HybridSynthesis(1) qmf_synthesis = QmfSynthesis(1) source = np.zeros((1, 1, 64), dtype=np.float64) qmf_values = qmf.process_chunk(source) hybrid_values = hybrid.process_chunk(qmf_values) back = hybrid_synthesis.process_chunk(hybrid_values) time_values = qmf_synthesis.process_chunk(back) self.assertEqual(qmf.history.dtype, np.float64) self.assertEqual(hybrid.high_history.dtype, np.complex128) self.assertEqual(qmf_values.dtype, np.complex128) self.assertEqual(hybrid_values.dtype, np.complex128) self.assertEqual(back.dtype, np.complex128) self.assertEqual(time_values.dtype, np.float64) impulse = identity_impulse_response(4096) self.assertEqual(int(np.argmax(np.abs(impulse))), ANALYSIS_SYNTHESIS_LATENCY_SAMPLES) self.assertAlmostEqual(float(impulse[961]), 0.9999959187778938, places=14) def test_filterbank_resource_limits_fingerprint_and_copy_isolation(self): first = load_filterbank_tables() second = load_filterbank_tables() self.assertFalse(np.shares_memory( first["qmf_synthesis_basis"], second["qmf_synthesis_basis"])) self.assertFalse(first["qmf_synthesis_basis"].flags.writeable) first_centers = hybrid_band_center_frequencies_hz() second_centers = hybrid_band_center_frequencies_hz() self.assertFalse(np.shares_memory(first_centers, second_centers)) self.assertFalse(first_centers.flags.writeable) expected_centers = second_centers.copy() first_centers.setflags(write=True) first_centers[0] = -1.0 np.testing.assert_array_equal( hybrid_band_center_frequencies_hz(), expected_centers) first_dictionary = hybrid_gain_synthesis_dictionary(16) second_dictionary = hybrid_gain_synthesis_dictionary(16) self.assertFalse(np.shares_memory(first_dictionary, second_dictionary)) self.assertFalse(first_dictionary.flags.writeable) expected_dictionary = second_dictionary.copy() first_dictionary.setflags(write=True) first_dictionary[0, 0] += 1.0 np.testing.assert_array_equal( hybrid_gain_synthesis_dictionary(16), expected_dictionary) expected_centers_hash = hashlib.sha256(np.asarray( hybrid_band_center_frequencies_hz(), dtype=" bytes: payload = io.BytesIO() np.save(payload, value, allow_pickle=False) return payload.getvalue() def write_archive(path: Path, replacement_name: str, replacement: bytes) -> None: with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: for name, value in tables.items(): if name == replacement_name: payload = replacement else: stored_value = (np.asfortranarray(value) if name == "hybrid_synthesis_indices" else value) payload = serialize(stored_value) archive.writestr(name + ".npy", payload) with tempfile.TemporaryDirectory() as directory: root = Path(directory) huge_shape = io.BytesIO() np.lib.format.write_array_header_1_0(huge_shape, { "descr": np.lib.format.dtype_to_descr(np.dtype("