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
1066 lines
49 KiB
Python
1066 lines
49 KiB
Python
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()
|