Files
JustOneCacophony/tests/test_binaural_production.py
T
TheM14 fe76aa1c72
Native builds / linux-x64 (push) Failing after 10s
Native builds / macos-arm64 (push) Has been cancelled
Native builds / macos-x64 (push) Has been cancelled
Native builds / windows-x64 (push) Has been cancelled
Native builds / Publish GitHub Release (push) Has been cancelled
添加双耳渲染功能
2026-09-06 18:59:44 +08:00

1066 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"<axml/>", 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("<I", head[16:20])[0], 28)
riff_size, ds_data, samples, table = struct.unpack(
"<QQQI", head[20:48])
self.assertEqual(riff_size, total - 8)
self.assertEqual(ds_data, data_len)
self.assertEqual(samples, sink.frames)
self.assertEqual(table, 0)
self.assertEqual(head[72:76], b"data")
self.assertEqual(
struct.unpack("<I", head[76:80])[0], 0xFFFFFFFF)
def test_adm_bwf_small_file_keeps_exact_data_size(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "small.wav"
sink = Sink25(path, 25, 48000)
sink.write_block(np.zeros((64, 25), dtype=np.float32))
sink.finalize(b"<axml/>", 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(
"<QQQI", head[20:48])
self.assertEqual(struct.unpack("<I", head[76:80])[0], data_len)
self.assertEqual(riff_size, total - 8)
self.assertEqual(ds_data, data_len)
self.assertEqual(samples, 64)
def _fibonacci_directions(count: int) -> 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="<f8").tobytes()).hexdigest().upper()
self.assertEqual(
filterbank_fingerprint()["band_centers_sha256"], expected_centers_hash)
with tempfile.TemporaryDirectory() as directory:
oversized = Path(directory) / "oversized.npz"
with zipfile.ZipFile(oversized, "w", zipfile.ZIP_DEFLATED) as archive:
for name in first:
payload = b"x" * (5000 if name == "format_version" else 1)
archive.writestr(name + ".npy", payload)
with self.assertRaisesRegex(ValueError, "unexpectedly large"):
load_filterbank_tables(oversized)
def test_filterbank_npy_headers_are_validated_before_loading(self):
tables = load_filterbank_tables()
def serialize(value) -> 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("<f4")),
"fortran_order": False,
"shape": (1_000_000_000,),
})
huge_shape_archive = root / "huge-shape.npz"
write_archive(
huge_shape_archive, "qmf_analysis_coefficients",
huge_shape.getvalue())
with self.assertRaisesRegex(ValueError, r"\.npy header.*expected"):
load_filterbank_tables(huge_shape_archive)
fortran_archive = root / "fortran.npz"
write_archive(
fortran_archive, "qmf_analysis_coefficients",
serialize(np.asfortranarray(
tables["qmf_analysis_coefficients"])))
with self.assertRaisesRegex(ValueError, "Fortran-order"):
load_filterbank_tables(fortran_archive)
truncated_archive = root / "truncated.npz"
write_archive(
truncated_archive, "format_version",
serialize(tables["format_version"])[:-1])
with self.assertRaisesRegex(ValueError, "payload size"):
load_filterbank_tables(truncated_archive)
def test_sofa_compile_and_cache_roundtrip_are_identical(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sofa = _write_sofa(root / "subject.sofa")
field = compile_sofa_hrtf(sofa, cache_policy="none")
cache = root / "subject.jochrtf"
field.save(cache)
loaded = SofaHrtfField.load(
cache,
expected_source_sha256=field.source_sha256,
expected_cache_key=field.cache_key)
np.testing.assert_array_equal(loaded.coefficients, field.coefficients)
np.testing.assert_array_equal(
loaded.delay_coefficients, field.delay_coefficients)
np.testing.assert_array_equal(
loaded.band_center_frequencies_hz,
field.band_center_frequencies_hz)
self.assertEqual(loaded.format_version, JOCHRTF_FORMAT_VERSION)
self.assertFalse(loaded.coefficients.flags.writeable)
self.assertTrue(loaded.coefficients.flags.owndata)
with np.load(cache, allow_pickle=False) as archive:
metadata = json.loads(str(archive["metadata_json"].item()))
self.assertEqual(metadata["magic"], JOCHRTF_MAGIC)
self.assertNotIn("source_path", metadata)
self.assertFalse(any(
str(root.resolve()) in str(value) for value in metadata.values()))
def test_none_and_memory_cache_policies_do_not_write_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sofa = _write_sofa(root / "subject.sofa")
cache_dir = root / "cache"
compile_sofa_hrtf(sofa, cache_policy="none", cache_dir=cache_dir)
compile_sofa_hrtf(sofa, cache_policy="memory", cache_dir=cache_dir)
self.assertFalse(cache_dir.exists())
def test_disk_cache_is_reusable(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sofa = _write_sofa(root / "subject.sofa")
cache_dir = root / "cache"
in_memory = compile_sofa_hrtf(sofa, cache_policy="memory")
first = compile_sofa_hrtf(
sofa, cache_policy="disk", cache_dir=cache_dir)
self.assertIs(first, in_memory)
caches = list(cache_dir.glob("*.jochrtf"))
self.assertEqual(len(caches), 1)
loaded = SofaHrtfField.load(caches[0])
self.assertEqual(loaded.cache_key, first.cache_key)
second = compile_sofa_hrtf(
sofa, cache_policy="disk", cache_dir=cache_dir)
self.assertEqual(second.cache_key, first.cache_key)
lock_files = [path for path in cache_dir.iterdir()
if path.name.endswith(".lock")]
self.assertEqual(len(lock_files), 1)
self.assertGreaterEqual(lock_files[0].stat().st_size, 1)
caches[0].write_bytes(b"damaged")
repaired = compile_sofa_hrtf(
sofa, cache_policy="disk", cache_dir=cache_dir)
self.assertEqual(repaired.cache_key, first.cache_key)
self.assertEqual(SofaHrtfField.load(caches[0]).cache_key, first.cache_key)
def test_cache_rejects_outdated_wrong_dtype_and_payload_tampering(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
good = root / "good.jochrtf"
_constant_field().save(good)
with np.load(good, allow_pickle=False) as archive:
metadata = str(archive["metadata_json"].item())
centers = archive["band_center_frequencies_hz"].copy()
coefficients = archive["coefficients"].copy()
delay_coefficients = archive["delay_coefficients"].copy()
delay_bounds = archive["delay_bounds"].copy()
wrong_dtype = root / "wrong-dtype.jochrtf"
with wrong_dtype.open("wb") as stream:
np.savez_compressed(
stream, metadata_json=np.asarray(metadata),
band_center_frequencies_hz=centers,
coefficients=coefficients.astype(np.complex64),
delay_coefficients=delay_coefficients,
delay_bounds=delay_bounds)
with self.assertRaisesRegex(HrtfCacheError, "<c16"):
SofaHrtfField.load(wrong_dtype)
tampered = root / "tampered.jochrtf"
coefficients[0, 0, 0] += 0.25
with tampered.open("wb") as stream:
np.savez_compressed(
stream, metadata_json=np.asarray(metadata),
band_center_frequencies_hz=centers,
coefficients=coefficients,
delay_coefficients=delay_coefficients,
delay_bounds=delay_bounds)
with self.assertRaisesRegex(HrtfCacheError, "payload hash"):
SofaHrtfField.load(tampered)
outdated = root / "outdated.jochrtf"
with outdated.open("wb") as stream:
np.savez_compressed(
stream,
metadata_json=np.asarray(json.dumps({"format_version": 2})),
band_center_frequencies_hz=centers,
coefficients=coefficients,
delay_coefficients=delay_coefficients,
delay_bounds=delay_bounds)
with self.assertRaisesRegex(HrtfCacheError, "version 2"):
SofaHrtfField.load(outdated)
def test_cache_npy_headers_are_validated_before_allocation(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
good = root / "good.jochrtf"
hostile = root / "hostile.jochrtf"
_constant_field().save(good)
with zipfile.ZipFile(good, "r") as archive:
members = {
member.filename: archive.read(member)
for member in archive.infolist()
}
huge_header = io.BytesIO()
np.lib.format.write_array_header_1_0(huge_header, {
"descr": np.lib.format.dtype_to_descr(np.dtype("<c16")),
"fortran_order": False,
"shape": (1_000_000_000_000,),
})
members["coefficients.npy"] = huge_header.getvalue()
with zipfile.ZipFile(hostile, "w", zipfile.ZIP_DEFLATED) as archive:
for name, payload in members.items():
archive.writestr(name, payload)
with self.assertRaisesRegex(HrtfCacheError, "coefficients must be"):
SofaHrtfField.load(hostile)
def test_cache_wraps_untrusted_json_numeric_and_recursion_errors(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
good = root / "good.jochrtf"
_constant_field().save(good)
with np.load(good, allow_pickle=False) as archive:
metadata = json.loads(str(archive["metadata_json"].item()))
arrays = {name: archive[name].copy() for name in archive.files
if name != "metadata_json"}
huge_number = root / "huge-number.jochrtf"
metadata["sample_rate_hz"] = 10 ** 400
with huge_number.open("wb") as stream:
np.savez_compressed(
stream, metadata_json=np.asarray(json.dumps(metadata)), **arrays)
with self.assertRaisesRegex(HrtfCacheError, "numeric range"):
SofaHrtfField.load(huge_number)
deep_json = root / "deep-json.jochrtf"
nested = '{"value":' + "[" * 5000 + "0" + "]" * 5000 + "}"
with deep_json.open("wb") as stream:
np.savez_compressed(
stream, metadata_json=np.asarray(nested), **arrays)
with self.assertRaisesRegex(HrtfCacheError, "metadata JSON"):
SofaHrtfField.load(deep_json)
def test_cache_rejects_noncausal_or_extreme_delay_fields(self):
field = _constant_field()
with self.assertRaisesRegex(ValueError, "non-negative"):
replace(field, delay_bounds=np.asarray([[-1.0, 0.0], [0.0, 0.0]]))
extreme = np.zeros((36, 2), dtype=np.float64)
extreme[0, 0] = 1.0e308
with self.assertRaisesRegex(ValueError, "delay coefficients"):
replace(field, delay_coefficients=extreme)
def test_cache_metadata_integer_types_are_strict(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
good = root / "good.jochrtf"
bad = root / "bad.jochrtf"
_constant_field().save(good)
with np.load(good, allow_pickle=False) as archive:
metadata = json.loads(str(archive["metadata_json"].item()))
arrays = {name: archive[name].copy() for name in archive.files
if name != "metadata_json"}
metadata["format_version"] = "1"
with bad.open("wb") as stream:
np.savez_compressed(
stream, metadata_json=np.asarray(json.dumps(metadata)), **arrays)
with self.assertRaisesRegex(HrtfCacheError, "must be an integer"):
SofaHrtfField.load(bad)
def test_cache_metadata_writer_and_loader_share_one_size_limit(self):
with tempfile.TemporaryDirectory() as directory:
cache = Path(directory) / "large-metadata.jochrtf"
field = replace(_constant_field(), fit_report={"padding": "x" * 50000})
field.save(cache)
self.assertEqual(
SofaHrtfField.load(cache).fit_report["padding"], "x" * 50000)
with self.assertRaisesRegex(HrtfCacheError, "metadata is too large"):
replace(_constant_field(), fit_report={"padding": "x" * 70000})
def test_explicit_compiled_cache_backend_and_latency(self):
with tempfile.TemporaryDirectory() as directory:
cache = Path(directory) / "field.jochrtf"
_constant_field().save(cache)
renderer = SofaBinauralRenderer.from_compiled_cache(
cache, chunk_frames=1, tail_seconds=0.0)
source = np.zeros((1536, 16), dtype=np.float64)
source[0, 1] = 1.0
first = renderer.render_frame(source)
capacity = renderer.finish_capacity_samples
tail = renderer.finish()
self.assertEqual(first.shape, (1536 - 961, 2))
self.assertEqual(first.dtype, np.float64)
self.assertLessEqual(len(tail), capacity)
self.assertEqual(renderer.backend_info["hrtf_input_kind"],
"compiled_cache")
self.assertEqual(renderer.finish().shape, (0, 2))
def test_backend_restores_128_sample_hrtf_delay_with_whole_qmf_slots(self):
reference_backend, reference = _render_delayed_field_impulse(0.0, 0.0)
delayed_backend, delayed = _render_delayed_field_impulse(128.0, 128.0)
self.assertEqual(reference_backend.hrtf_history_slots, 0)
self.assertEqual(delayed_backend.hrtf_history_slots, 2)
self.assertEqual(
delayed_backend.paths.history_slots,
delayed_backend.early_history_slots + 2)
self.assertEqual(int(np.argmax(np.abs(delayed[:, 0]))), 128)
self.assertEqual(int(np.argmax(np.abs(delayed[:, 1]))), 128)
np.testing.assert_allclose(
delayed[128:128 + len(reference)], reference,
rtol=0.0, atol=1.0e-12)
def test_field_impulse_helper_restores_whole_qmf_delay_slots(self):
reference = _constant_field_with_delays(0.0, 0.0).render_impulse(
(1.0, 0.0, 0.0), sample_count=512)
delayed = _constant_field_with_delays(128.0, 128.0).render_impulse(
(1.0, 0.0, 0.0), sample_count=512)
self.assertEqual(int(np.argmax(np.abs(delayed[0]))), 128)
self.assertEqual(int(np.argmax(np.abs(delayed[1]))), 128)
np.testing.assert_allclose(
delayed[:, 128:], reference[:, :-128], rtol=0.0, atol=1.0e-12)
def test_backend_combines_per_ear_fractional_hrtf_and_whole_slot_delay(self):
_, residual = _render_delayed_field_impulse(0.5, 65.25)
backend, delayed = _render_delayed_field_impulse(
0.5, 65.25, path_delay=128.0)
self.assertEqual(backend.hrtf_history_slots, 2)
self.assertEqual(int(np.argmax(np.abs(delayed[:, 0]))), 128)
self.assertEqual(int(np.argmax(np.abs(delayed[:, 1]))), 193)
np.testing.assert_allclose(
delayed[128:128 + len(residual), 0], residual[:, 0],
rtol=0.0, atol=1.0e-12)
np.testing.assert_allclose(
delayed[128:128 + len(residual), 1], residual[:, 1],
rtol=0.0, atol=1.0e-12)
def test_finish_capacity_covers_maximum_legal_early_delay(self):
backend = SofaBinauralBackend(
_constant_field(), source_count=1, history_slots=256,
enable_late_room=False)
with self.assertRaisesRegex(ValueError, "non-negative"):
backend.finish(tail_seconds=-1.0e-6)
backend.process(np.zeros((1024, 1), dtype=np.float64))
backend.maximum_early_delay_samples = 256 * 64 - 1.0e-6
capacity = backend.finish_output_capacity(0.0)
tail = backend.finish(tail_seconds=0.0)
self.assertLessEqual(len(tail), capacity)
def test_joc_adapter_updates_each_512_sample_block(self):
backend = _RecordingBackend()
renderer = SofaBinauralRenderer(
backend, mode="mid", chunk_frames=2, tail_seconds=0.25)
source = np.zeros((1536, 16), dtype=np.float64)
self.assertEqual(renderer.render_frame(source).shape, (0, 2))
output = renderer.render_frame(source)
self.assertEqual(output.shape, (3072, 2))
self.assertEqual(len(backend.process_calls), 6)
self.assertTrue(all(call.shape == (512, 16)
for call in backend.process_calls))
self.assertEqual(len(backend.calls), 6 * 16)
for block in range(6):
first = backend.calls[block * 16]
self.assertEqual(first[0], 0)
self.assertTrue(first[2]["special_lfe"])
self.assertEqual(first[2]["profile"], "mid")
tail = renderer.finish()
self.assertEqual(tail.shape, (64, 2))
self.assertEqual(backend.finish_calls, 1)
self.assertEqual(backend.finish_tail_seconds, 0.25)
def test_oamd_timing_retains_outer_delay_and_ramp(self):
timeline = OamdPositionTimeline()
initial = {
"values": {
(1, "q1"): q_of(0, 62),
(1, "q2"): q_of(0, 62),
(1, "q3"): q_of(0, 15),
},
"block_offset_samples": 0,
"ramp_duration_samples": 1536,
}
timeline.submit_update(initial, frame_start_sample=0)
old = np.asarray(q_to_adm_xyz(q_of(0, 62), q_of(0, 62), q_of(0, 15)))
target_q1 = q_of(62, 62)
timeline.submit_update(
{"values": {(1, "q1"): target_q1},
"block_offset_samples": 32,
"ramp_duration_samples": 1536},
frame_start_sample=1536,
outer_sample_offset=16,
object_delay_samples=1473)
start = 1536 + 16 + 32 + 1473 + 64
duration = 1536 - 64
target = np.asarray(q_to_adm_xyz(
target_q1, q_of(0, 62), q_of(0, 15)))
np.testing.assert_allclose(timeline.positions_at(start)[0], old)
np.testing.assert_allclose(
timeline.positions_at(start + duration // 2)[0],
old + (target - old) * 0.5)
np.testing.assert_allclose(timeline.positions_at(start + duration)[0], target)
def test_project_room_and_distance_contract(self):
near = ReferenceDistanceProfileV1.map_adm_position((0, 1, 0), "near")
mid = ReferenceDistanceProfileV1.map_adm_position((0, 1, 0), "mid")
far = ReferenceDistanceProfileV1.map_adm_position((0, 1, 0), "far")
self.assertGreater(
ReferenceDistanceProfileV1.direct_level_gain(near),
ReferenceDistanceProfileV1.direct_level_gain(mid))
self.assertGreater(
ReferenceDistanceProfileV1.direct_level_gain(mid),
ReferenceDistanceProfileV1.direct_level_gain(far))
self.assertLess(ReferenceDistanceProfileV1.direct_level_gain(mid), 1.0)
self.assertGreater(ReferenceDistanceProfileV1.direct_level_gain(mid), 0.8)
self.assertEqual(len(first_order_image_sources((0, 1, 0), 2.0, 48000)), 6)
fdn = SharedUnitaryFdn()
np.testing.assert_allclose(fdn.matrix.T @ fdn.matrix, np.eye(4))
def test_room_configuration_rejects_nonfinite_and_malformed_values(self):
with self.assertRaisesRegex(ValueError, "RT60.*finite"):
SharedUnitaryFdn(LateFdnConfig(rt60_seconds=float("nan")))
for config, message in (
(LateFdnConfig(delay_seconds=(0.03, 0.04, 0.05)),
"four positive finite delay"),
(LateFdnConfig(delay_seconds=(0.03, 0.04, 0.05, 0.0)),
"four positive finite delay"),
(LateFdnConfig(allpass_seconds=(0.002,)),
"two positive finite all-pass delay"),
(LateFdnConfig(allpass_gain=(0.5, float("nan"))),
"two finite all-pass gains")):
with self.subTest(config=config):
with self.assertRaisesRegex(ValueError, message):
SharedUnitaryFdn(config)
with self.assertRaisesRegex(ValueError, "positive finite"):
ShoeboxRoomConfig(
dimensions_m=(18.0, float("nan"), 14.0)).validate()
def test_binaural_spool_preserves_float64_and_trims_tail(self):
with tempfile.TemporaryDirectory() as directory:
raw = Path(directory) / "binaural.raw"
wav = Path(directory) / "binaural.wav"
with self.assertRaisesRegex(ValueError, "finite"):
BinauralPcmSpool(
Path(directory) / "invalid.raw", 8, tail_threshold=float("nan"))
spool = BinauralPcmSpool(raw, 8, tail_threshold=1.0e-8)
values = np.asarray([
[0.0, 0.0], [0.25, -0.25],
[1.0 + 2.0 ** -40, 0.0], [1.0e-9, 0.0]], dtype=np.float64)
spool.write_frame(values)
spool.finalize(minimum_samples=2)
self.assertEqual(spool.values.dtype, np.float64)
self.assertEqual(spool.sample_count, 3)
self.assertEqual(spool.clipped_values, 1)
info = write_pcm_wav(wav, spool.values, "float32")
self.assertEqual((info["sample_count"], info["channel_count"]), (3, 2))
spool.close()
def test_public_runtime_import_graph_has_no_private_format_modules(self):
forbidden = (
"personalized_headphone", "rosella_model", "rosella_core",
"rosella_direct", "rosella_room", "binaural_native_renderer")
production = (
"binaural_renderer.py", "sofa_binaural_backend.py",
"sofa_hrtf_field.py", "sofa_canonical.py", "public_filterbank.py",
"public_room.py", "reference_distance.py")
joined = "\n".join(
(SRC / name).read_text(encoding="utf-8").lower()
for name in production)
for token in forbidden:
self.assertNotIn(token, joined)
if __name__ == "__main__":
unittest.main()