Add binaural rendering support.
Native builds / linux-x64 (push) Failing after 11s
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

This commit is contained in:
2026-09-06 19:14:24 +08:00
parent 329445ed25
commit 85105f21d4
38 changed files with 9660 additions and 214 deletions
+17 -9
View File
@@ -234,11 +234,21 @@ def build_dbmd(object_count=25, joc_binaural_mode=4):
return bytes(out)
class Sink25:
"""RF64 ADM BWF writer.
Header layout is fixed so that sizes can be patched without rereading the
file: RF64+size+WAVE (12) + ds64 chunk (8+28) + fmt chunk (8+16) + data
chunk header (8). Sizes beyond 32 bits follow the RF64 convention: the
chunk size field holds 0xFFFFFFFF and the true value lives in ds64.
"""
_DS64_BODY_OFFSET = 20
_DATA_SIZE_OFFSET = 76
def __init__(self, path, channels, rate):
self.ch = channels; self.rate = rate; self.frames = 0
self.fp = open(path, "wb+")
self.fp.write(b"RF64" + struct.pack("<I", 0xFFFFFFFF) + b"WAVE")
self._chunk(b"ds64", b"\x00" * 64)
self._chunk(b"ds64", b"\x00" * 28)
self._chunk(b"fmt ", self._fmt())
self._chunk(b"data", b"")
def _chunk(self, cid, body):
@@ -259,14 +269,12 @@ class Sink25:
self._chunk(b"chna", chna_bytes)
self._chunk(b"dbmd", dbmd_bytes)
self.fp.seek(0, 2); total = self.fp.tell()
self.fp.seek(0); head = self.fp.read()
m = head.find(b"data")
if m >= 0:
self.fp.seek(m + 4); self.fp.write(struct.pack("<I", data_len))
m = head.find(b"ds64")
if m >= 0:
self.fp.seek(m + 8)
self.fp.write(struct.pack("<QQQI", total - 8, data_len, self.frames, 0))
# RF64: 超过 32-bit 的 chunk size 字段写 0xFFFFFFFF,真实大小回填 ds64。
self.fp.seek(self._DATA_SIZE_OFFSET)
self.fp.write(struct.pack(
"<I", data_len if data_len <= 0xFFFFFFFF else 0xFFFFFFFF))
self.fp.seek(self._DS64_BODY_OFFSET)
self.fp.write(struct.pack("<QQQI", total - 8, data_len, self.frames, 0))
self.fp.flush()
self.fp.close()
+188
View File
@@ -0,0 +1,188 @@
"""Direct ID11/OAMD position scheduling for the binaural render path."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from adm_atmos import q_to_adm_xyz
from oamd_bits import JocFieldState, frame_update
from variant_error import UnsupportedVariantError
OAMD_UPDATE_QUANTUM_SAMPLES = 64
@dataclass(frozen=True)
class PositionTransition:
start_sample: int
duration_samples: int
origin: np.ndarray
target: np.ndarray
@property
def end_sample(self) -> int:
return self.start_sample + self.duration_samples
class _ObjectPositionTrack:
def __init__(self):
self.initial = np.zeros(3, dtype=np.float64)
self.last_target = self.initial.copy()
self.transitions: list[PositionTransition] = []
self.cursor = 0
self.last_query_sample = -1
def set_initial(self, position):
target = np.asarray(position, dtype=np.float64)
self.initial = target.copy()
self.last_target = target.copy()
def append(self, start_sample: int, duration_samples: int, target,
object_index: int):
start = int(start_sample)
duration = int(duration_samples)
if start < 0 or duration < 0:
raise ValueError("position transition timing must be non-negative")
target = np.asarray(target, dtype=np.float64)
if self.transitions:
previous = self.transitions[-1]
if start < previous.end_sample:
raise UnsupportedVariantError(
"oamd", "overlapping_binaural_position_ramps",
"同一对象的新位置更新在上一双耳 ramp 完成前到达",
details={
"object": object_index,
"ramp_start_sample": previous.start_sample,
"ramp_end_sample": previous.end_sample,
"next_update_sample": start,
})
if start == previous.start_sample and previous.duration_samples == 0:
self.transitions[-1] = PositionTransition(
start, duration, previous.origin.copy(), target.copy())
self.last_target = target.copy()
return
self.transitions.append(PositionTransition(
start, duration, self.last_target.copy(), target.copy()))
self.last_target = target.copy()
def position_at(self, sample: int) -> np.ndarray:
sample = int(sample)
if sample < self.last_query_sample:
raise ValueError("binaural metadata positions must be queried monotonically")
self.last_query_sample = sample
while self.cursor < len(self.transitions):
transition = self.transitions[self.cursor]
if sample < transition.end_sample:
break
self.initial = transition.target.copy()
self.cursor += 1
if self.cursor >= len(self.transitions):
return self.initial
transition = self.transitions[self.cursor]
if sample < transition.start_sample:
return self.initial
if transition.duration_samples == 0:
return transition.target
amount = (sample - transition.start_sample) / float(transition.duration_samples)
return transition.origin + (transition.target - transition.origin) * amount
class OamdPositionTimeline:
"""Convert OAMD state updates into a sample-timed Cartesian trajectory."""
def __init__(self, object_count: int = 15):
if object_count != 15:
raise ValueError("JOC OAMD currently requires 15 object slots")
self.object_count = int(object_count)
self.state = JocFieldState()
self.tracks = [_ObjectPositionTrack() for _ in range(self.object_count)]
self.initialized = False
self.previous_targets: list[tuple[float, float, float] | None] = [
None] * self.object_count
self.payload_count = 0
self.transition_count = 0
self.last_coded_event_sample = -1
def _targets(self) -> list[tuple[float, float, float]]:
q = self.state.q
return [
q_to_adm_xyz(
q[(object_index, "q1")],
q[(object_index, "q2")],
q[(object_index, "q3")],
)
for object_index in range(1, self.object_count + 1)
]
def submit_update(self, update: dict, *, frame_start_sample: int,
outer_sample_offset: int = 0,
object_delay_samples: int = 1473,
processed_sample: int = 0):
"""Schedule one already-parsed :func:`oamd_bits.frame_update` result."""
frame_start = int(frame_start_sample)
outer_offset = int(outer_sample_offset)
object_delay = int(object_delay_samples)
if min(frame_start, outer_offset, object_delay) < 0:
raise ValueError("OAMD frame, outer offset, and object delay must be non-negative")
self.state.apply(update["values"])
targets = self._targets()
coded_event = (
frame_start + outer_offset + int(update["block_offset_samples"]))
if coded_event < self.last_coded_event_sample:
raise UnsupportedVariantError(
"oamd", "non_monotonic_binaural_updates",
"双耳 OAMD 更新时间倒退",
details={
"event_sample": coded_event,
"previous_event_sample": self.last_coded_event_sample,
})
self.last_coded_event_sample = coded_event
if not self.initialized:
if int(processed_sample) > 0:
raise UnsupportedVariantError(
"oamd", "late_initial_binaural_state",
"首个 OAMD 状态在双耳 PCM 已处理后才出现,无法回填 sample 0",
details={
"processed_sample": int(processed_sample),
"first_event_sample": coded_event,
})
for index, target in enumerate(targets):
self.tracks[index].set_initial(target)
self.previous_targets[index] = target
self.initialized = True
self.payload_count += 1
return
ramp_duration = int(update["ramp_duration_samples"])
effective_ramp = max(0, ramp_duration - OAMD_UPDATE_QUANTUM_SAMPLES)
transition_start = coded_event + object_delay
if effective_ramp:
transition_start += OAMD_UPDATE_QUANTUM_SAMPLES
for index, target in enumerate(targets):
if self.previous_targets[index] == target:
continue
self.tracks[index].append(
transition_start, effective_ramp, target, index + 1)
self.previous_targets[index] = target
self.transition_count += 1
self.payload_count += 1
def submit_payload(self, payload, *, frame_start_sample: int,
outer_sample_offset: int = 0,
object_delay_samples: int = 1473,
processed_sample: int = 0):
update = frame_update(payload)
self.submit_update(
update,
frame_start_sample=frame_start_sample,
outer_sample_offset=outer_sample_offset,
object_delay_samples=object_delay_samples,
processed_sample=processed_sample,
)
return update
def positions_at(self, sample: int) -> np.ndarray:
return np.stack(
[track.position_at(sample) for track in self.tracks], axis=0
).astype(np.float64, copy=False)
+214
View File
@@ -0,0 +1,214 @@
"""ctypes bridge for the native float64 binaural DSP."""
from __future__ import annotations
import ctypes
from pathlib import Path
import numpy as np
from native_renderer import ABI_VERSION, find_native_library
from rosella_filterbank import DEFAULT_KERNEL_DATA, load_kernel_tables
from rosella_model import RosellaModel
BLOCK_SAMPLES = 512
INPUT_CHANNELS = 16
OUTPUT_CHANNELS = 2
HYBRID_BANDS = 77
class NativeBinauralDsp:
def __init__(self, model: RosellaModel, *, library_path=None,
kernel_data: str | Path = DEFAULT_KERNEL_DATA):
self.library_path = find_native_library(library_path)
self._lib = ctypes.CDLL(str(self.library_path))
self._bind()
version = int(self._lib.ejoc_abi_version())
if version != ABI_VERSION:
raise RuntimeError(
f"native ABI mismatch: expected {ABI_VERSION}, got {version}")
self._handle = self._lib.ejoc_binaural_renderer_create()
if not self._handle:
raise RuntimeError("native binaural renderer creation failed")
try:
self._configure_kernels(kernel_data)
self._configure_room(model)
except Exception:
self.close()
raise
def _bind(self):
void_p = ctypes.c_void_p
f64_p = ctypes.POINTER(ctypes.c_double)
i16_p = ctypes.POINTER(ctypes.c_int16)
u32_p = ctypes.POINTER(ctypes.c_uint32)
self._lib.ejoc_abi_version.argtypes = []
self._lib.ejoc_abi_version.restype = ctypes.c_uint32
self._lib.ejoc_binaural_renderer_create.argtypes = []
self._lib.ejoc_binaural_renderer_create.restype = void_p
self._lib.ejoc_binaural_renderer_destroy.argtypes = [void_p]
self._lib.ejoc_binaural_renderer_destroy.restype = None
self._lib.ejoc_binaural_renderer_reset.argtypes = [void_p]
self._lib.ejoc_binaural_renderer_reset.restype = ctypes.c_int
self._lib.ejoc_binaural_renderer_last_error.argtypes = [void_p]
self._lib.ejoc_binaural_renderer_last_error.restype = ctypes.c_char_p
self._lib.ejoc_binaural_renderer_configure_kernels.argtypes = [
void_p, f64_p, f64_p, i16_p, f64_p, ctypes.c_uint32, f64_p, f64_p]
self._lib.ejoc_binaural_renderer_configure_kernels.restype = ctypes.c_int
self._lib.ejoc_binaural_renderer_configure_room.argtypes = [
void_p, ctypes.c_uint32, ctypes.c_uint32, u32_p, f64_p,
u32_p, f64_p, ctypes.c_uint32, f64_p, f64_p, f64_p,
ctypes.c_uint32, u32_p, f64_p, f64_p]
self._lib.ejoc_binaural_renderer_configure_room.restype = ctypes.c_int
self._lib.ejoc_binaural_renderer_process.argtypes = [
void_p, f64_p, f64_p, f64_p, ctypes.c_double, f64_p]
self._lib.ejoc_binaural_renderer_process.restype = ctypes.c_int
def _raise(self, operation, status):
message = self._lib.ejoc_binaural_renderer_last_error(self._handle)
detail = (message or b"").decode("utf-8", "replace")
raise RuntimeError(
f"native binaural renderer {operation} failed ({status}): {detail}")
@staticmethod
def _f64_pointer(values):
return values.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
def _configure_kernels(self, kernel_data):
tables = load_kernel_tables(kernel_data)
qmf_analysis = np.ascontiguousarray(
tables["qmf_analysis_coefficients"], dtype=np.float64)
hybrid_low = np.ascontiguousarray(
tables["hybrid_analysis_low_kernel"], dtype=np.float64)
hybrid_indices = np.ascontiguousarray(
tables["hybrid_synthesis_indices"], dtype=np.int16)
hybrid_values = np.ascontiguousarray(
tables["hybrid_synthesis_values"], dtype=np.float64)
qmf_basis = np.ascontiguousarray(
tables["qmf_synthesis_basis"], dtype=np.float64)
qmf_taps = np.ascontiguousarray(
tables["qmf_synthesis_taps"], dtype=np.float64)
status = self._lib.ejoc_binaural_renderer_configure_kernels(
self._handle,
self._f64_pointer(qmf_analysis),
self._f64_pointer(hybrid_low),
hybrid_indices.ctypes.data_as(ctypes.POINTER(ctypes.c_int16)),
self._f64_pointer(hybrid_values),
len(hybrid_values),
self._f64_pointer(qmf_basis),
self._f64_pointer(qmf_taps),
)
if status:
self._raise("configure_kernels", status)
def _configure_room(self, model: RosellaModel):
if float(model.table_a_scalar) >= 0.5:
raise NotImplementedError("alternate table-A room mode")
bands = min(64, model.table_a_dimension)
allpass_delays = np.ascontiguousarray(
model.table_a_option_ids, dtype=np.uint32)
allpass_gains = np.ascontiguousarray(
model.table_a_option_values, dtype=np.float64)
fdn_delays = np.ascontiguousarray(
model.table_a_four_integers, dtype=np.uint32)
fdn_matrix = np.ascontiguousarray(
np.asarray(model.table_a_vector16, dtype=np.float64).reshape(
4, 4, order="F"))
filter8 = np.asarray(
model.table_a_filter_8x64_padded, dtype=np.float64).reshape(20, 4, 2, 4)
filter4 = np.asarray(
model.table_a_filter_4x64_padded, dtype=np.float64).reshape(20, 4, 4)
filter16 = np.asarray(
model.table_a_filter_16x64_padded, dtype=np.float64).reshape(20, 4, 4, 4)
feedback = np.empty((64, 4, 2), dtype=np.float64)
output_taps = np.empty((64, 4), dtype=np.float64)
output_matrix = np.empty((2, 64, 4, 2), dtype=np.float64)
for band in range(64):
group, lane = divmod(band, 4)
feedback[band, :, 0] = filter8[group, :, 0, lane]
feedback[band, :, 1] = filter8[group, :, 1, lane]
output_taps[band] = filter4[group, :, lane]
output_matrix[0, band, :, 0] = filter16[group, :, 0, lane]
output_matrix[0, band, :, 1] = filter16[group, :, 1, lane]
output_matrix[1, band, :, 0] = filter16[group, :, 2, lane]
output_matrix[1, band, :, 1] = filter16[group, :, 3, lane]
extra_count = int(model.table_a_extra)
extra_delays = np.ascontiguousarray(
model.table_a_extra_indices, dtype=np.uint32)
extra_fields = np.empty((extra_count, 64, 2), dtype=np.float64)
extra_source = np.asarray(
model.table_a_extra_fields_padded, dtype=np.float64).reshape(
extra_count, 20, 2, 4)
for extra in range(extra_count):
for band in range(64):
group, lane = divmod(band, 4)
extra_fields[extra, band] = extra_source[extra, group, :, lane]
extra_matrices = np.empty((extra_count, 4, 4), dtype=np.float64)
for extra in range(extra_count):
extra_matrices[extra] = np.asarray(
model.table_a_extra_vectors[extra], dtype=np.float64).reshape(
4, 4, order="F")
null_u32 = ctypes.POINTER(ctypes.c_uint32)()
null_f64 = ctypes.POINTER(ctypes.c_double)()
status = self._lib.ejoc_binaural_renderer_configure_room(
self._handle,
bands,
len(allpass_delays),
allpass_delays.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)),
self._f64_pointer(allpass_gains),
fdn_delays.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)),
self._f64_pointer(fdn_matrix),
int(model.table_a_integer),
self._f64_pointer(feedback),
self._f64_pointer(output_taps),
self._f64_pointer(output_matrix),
extra_count,
(extra_delays.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32))
if extra_count else null_u32),
self._f64_pointer(extra_fields) if extra_count else null_f64,
self._f64_pointer(extra_matrices) if extra_count else null_f64,
)
if status:
self._raise("configure_room", status)
def reset(self):
if not self._handle:
raise RuntimeError("native binaural renderer is closed")
status = self._lib.ejoc_binaural_renderer_reset(self._handle)
if status:
self._raise("reset", status)
def process_block(self, pcm16, gains, room_sends, output_gain=1.0):
if not self._handle:
raise RuntimeError("native binaural renderer is closed")
source = np.ascontiguousarray(pcm16, dtype=np.float64)
gain_values = np.asarray(gains)
sends = np.ascontiguousarray(room_sends, dtype=np.float64)
if source.shape != (BLOCK_SAMPLES, INPUT_CHANNELS):
raise ValueError(f"pcm16 block must be (512,16), got {source.shape}")
if gain_values.shape != (INPUT_CHANNELS, OUTPUT_CHANNELS, HYBRID_BANDS):
raise ValueError(f"gains must be (16,2,77), got {gain_values.shape}")
direct = np.ascontiguousarray(
gain_values, dtype=np.complex128).view(np.float64)
if sends.shape != (INPUT_CHANNELS,):
raise ValueError(f"room_sends must be (16,), got {sends.shape}")
output = np.empty((BLOCK_SAMPLES, OUTPUT_CHANNELS), dtype=np.float64)
status = self._lib.ejoc_binaural_renderer_process(
self._handle,
self._f64_pointer(source),
self._f64_pointer(direct),
self._f64_pointer(sends),
float(output_gain),
self._f64_pointer(output),
)
if status:
self._raise("process", status)
return output
def close(self):
handle = getattr(self, "_handle", None)
if handle:
self._lib.ejoc_binaural_renderer_destroy(handle)
self._handle = None
+272
View File
@@ -0,0 +1,272 @@
"""JOC frame adapter for the public SOFA binaural backend."""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
from binaural_metadata import OamdPositionTimeline
from public_filterbank import ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
from sofa_binaural_backend import SofaBinauralBackend
from sofa_hrtf_field import (
DEFAULT_HRTF_CACHE_DIR,
DEFAULT_PROJECTION_RIDGE,
DEFAULT_SH_RIDGE,
)
SAMPLE_RATE = 48000
FRAME_SAMPLES = 1536
BINAURAL_BLOCK_SAMPLES = 512
QMF_HOP_SAMPLES = 64
BINAURAL_LATENCY_SAMPLES = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
SOURCE_CHANNELS = 16
OUTPUT_CHANNELS = 2
PROJECT_DIR = Path(__file__).resolve().parent.parent
DEFAULT_HRTF_DIR = PROJECT_DIR / "HRTF"
DEFAULT_SOFA_HRTF = DEFAULT_HRTF_DIR / "binaural.sofa"
def _resolve_hrtf_file(path: str | Path, suffix: str, label: str) -> Path:
target = Path(path).expanduser().resolve()
if target.suffix.lower() != suffix:
raise ValueError(f"{label} must use the {suffix} extension: {target}")
if not target.is_file():
raise FileNotFoundError(f"{label} not found: {target}")
return target
def resolve_sofa_hrtf(path: str | Path) -> Path:
"""Resolve an explicitly selected public SOFA source."""
return _resolve_hrtf_file(path, ".sofa", "SOFA HRTF")
def resolve_compiled_hrtf_cache(path: str | Path) -> Path:
"""Resolve an explicitly selected JOC compiled HRTF cache."""
return _resolve_hrtf_file(path, ".jochrtf", "compiled HRTF cache")
class SofaBinauralRenderer:
"""Render interleaved LFE plus fifteen JOC objects to stereo.
The adapter owns frame buffering and sample-timed OAMD updates. The
backend owns the 64-QMF/77-hybrid state, the 961-sample latency policy,
per-object direct/early state, and the shared late room.
"""
def __init__(
self, backend, *,
mode: str = "mid",
object_delay_samples: int = 1473,
tail_seconds: float = 5.0,
chunk_frames: int = 64):
required_interface = (
"source_count", "default_profile", "set_source", "process",
"finish", "finish_output_capacity", "info")
missing = [name for name in required_interface if not hasattr(backend, name)]
if missing:
raise TypeError(
f"backend must implement the binaural backend interface; "
f"missing: {', '.join(missing)}")
if backend.source_count != SOURCE_CHANNELS:
raise ValueError(f"JOC binaural backend must have {SOURCE_CHANNELS} sources")
if backend.default_profile != str(mode).lower():
raise ValueError("backend default profile does not match renderer mode")
if int(object_delay_samples) < 0:
raise ValueError("object_delay_samples must be non-negative")
if not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0:
raise ValueError("tail_seconds must be finite and non-negative")
if int(chunk_frames) <= 0:
raise ValueError("chunk_frames must be positive")
self.backend = backend
self.mode = str(mode).lower()
self.object_delay_samples = int(object_delay_samples)
self.tail_seconds = float(tail_seconds)
self.chunk_frames = int(chunk_frames)
self.chunk_samples = self.chunk_frames * FRAME_SAMPLES
self.dsp_backend = getattr(backend, "dsp_backend", "python-sofa")
self.timeline = OamdPositionTimeline(15)
self._input_buffer = np.empty(
(self.chunk_samples, SOURCE_CHANNELS), dtype=np.float64)
self._buffer_used = 0
self.input_samples = 0
self.processed_input_samples = 0
self.output_samples = 0
self.finished = False
self.metadata_block_updates = 0
@classmethod
def from_sofa(
cls, sofa: str | Path, *,
mode: str = "mid",
cache_policy: str = "memory",
cache_dir: str | Path | None = DEFAULT_HRTF_CACHE_DIR,
shell_radius_m: float = 1.0,
projection_ridge: float = DEFAULT_PROJECTION_RIDGE,
sh_ridge: float = DEFAULT_SH_RIDGE,
object_delay_samples: int = 1473,
tail_seconds: float = 5.0,
output_gain: float = 1.0,
chunk_frames: int = 64) -> "SofaBinauralRenderer":
source = resolve_sofa_hrtf(sofa)
backend = SofaBinauralBackend.from_sofa(
source,
source_count=SOURCE_CHANNELS,
default_profile=mode,
output_gain=output_gain,
cache_policy=cache_policy,
cache_dir=cache_dir,
shell_radius_m=shell_radius_m,
projection_ridge=projection_ridge,
sh_ridge=sh_ridge)
return cls(
backend,
mode=mode,
object_delay_samples=object_delay_samples,
tail_seconds=tail_seconds,
chunk_frames=chunk_frames)
@classmethod
def from_compiled_cache(
cls, cache: str | Path, *,
mode: str = "mid",
object_delay_samples: int = 1473,
tail_seconds: float = 5.0,
output_gain: float = 1.0,
chunk_frames: int = 64) -> "SofaBinauralRenderer":
source = resolve_compiled_hrtf_cache(cache)
backend = SofaBinauralBackend.from_compiled_cache(
source,
source_count=SOURCE_CHANNELS,
default_profile=mode,
output_gain=output_gain)
return cls(
backend,
mode=mode,
object_delay_samples=object_delay_samples,
tail_seconds=tail_seconds,
chunk_frames=chunk_frames)
@property
def finish_capacity_samples(self) -> int:
return self.backend.finish_output_capacity(self.tail_seconds)
def _append_input(self, samples: np.ndarray) -> list[np.ndarray]:
outputs = []
source = np.asarray(samples, dtype=np.float64)
position = 0
while position < len(source):
count = min(self.chunk_samples - self._buffer_used, len(source) - position)
self._input_buffer[self._buffer_used:self._buffer_used + count] = (
source[position:position + count])
self._buffer_used += count
position += count
if self._buffer_used == self.chunk_samples:
outputs.append(self._process_samples(self._input_buffer))
self._buffer_used = 0
return outputs
def render_frame(self, objects16, payload=None, metadata_offset=None,
*, outer_sample_offset=0) -> np.ndarray:
"""Submit one 1536-sample reconstructed frame and its ID11 payload."""
if self.finished:
raise RuntimeError("binaural renderer is already finished")
source = np.asarray(objects16)
if source.shape != (FRAME_SAMPLES, SOURCE_CHANNELS):
raise ValueError(
f"binaural frame must have shape ({FRAME_SAMPLES},{SOURCE_CHANNELS}), "
f"got {source.shape}")
frame_start = self.input_samples
metadata_delay = (self.object_delay_samples if metadata_offset is None
else int(metadata_offset))
if metadata_delay < 0:
raise ValueError("metadata_offset must be non-negative")
if payload is not None:
self.timeline.submit_payload(
payload,
frame_start_sample=frame_start,
outer_sample_offset=int(outer_sample_offset),
object_delay_samples=metadata_delay,
processed_sample=self.processed_input_samples,
)
self.metadata_block_updates += 1
self.input_samples += FRAME_SAMPLES
chunks = self._append_input(source)
if not chunks:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
return np.concatenate(chunks, axis=0) if len(chunks) > 1 else chunks[0]
def _set_block_parameters(self, sample: int) -> None:
positions = self.timeline.positions_at(sample)
self.backend.set_source(
0, (0.0, 1.0, 0.0), profile=self.mode,
special_lfe=True)
for object_index in range(15):
self.backend.set_source(
object_index + 1,
positions[object_index],
profile=self.mode)
def _process_samples(self, source: np.ndarray) -> np.ndarray:
values = np.asarray(source, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != SOURCE_CHANNELS:
raise ValueError(f"expected [samples,{SOURCE_CHANNELS}], got {values.shape}")
if len(values) % BINAURAL_BLOCK_SAMPLES:
raise ValueError("binaural input must be divisible by 512 samples")
outputs = []
block_base = self.processed_input_samples
for start in range(0, len(values), BINAURAL_BLOCK_SAMPLES):
sample = block_base + start
self._set_block_parameters(sample)
outputs.append(self.backend.process(
values[start:start + BINAURAL_BLOCK_SAMPLES]))
self.processed_input_samples += len(values)
nonempty = [value for value in outputs if len(value)]
if not nonempty:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
output = np.concatenate(nonempty, axis=0)
self.output_samples += len(output)
return output
def finish(self) -> np.ndarray:
"""Process pending source samples and drain early/late room state once."""
if self.finished:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
outputs: list[np.ndarray] = []
if self._buffer_used:
outputs.append(self._process_samples(
self._input_buffer[:self._buffer_used]))
self._buffer_used = 0
outputs.append(self.backend.finish(tail_seconds=self.tail_seconds))
self.finished = True
nonempty = [value for value in outputs if len(value)]
if not nonempty:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
output = np.concatenate(nonempty, axis=0)
self.output_samples += len(outputs[-1])
return output
def close(self) -> None:
self.finished = True
@property
def backend_info(self) -> dict:
info = self.backend.info()
info.update({
"adapter": "JOC 1536-frame / 512-sample metadata",
"dsp_backend": self.dsp_backend,
"mode": self.mode,
"latency_compensated_samples": BINAURAL_LATENCY_SAMPLES,
"object_delay_samples": self.object_delay_samples,
"tail_seconds": self.tail_seconds,
"metadata_payloads": self.timeline.payload_count,
"metadata_position_transitions": self.timeline.transition_count,
"input_samples": self.input_samples,
"source_samples_processed": self.processed_input_samples,
"output_samples_before_tail_trim": self.output_samples,
"thread_safe": False,
})
return info
+692
View File
@@ -0,0 +1,692 @@
"""Public 64-QMF and 77-band hybrid filterbank for binaural rendering.
The fixed resource is ``data/rosella_kernels.npz``: the fixed 64-QMF /
``3 -> 8+4+4`` 77-hybrid analysis tables and the causal synthesis tables
computed from that analysis bank. The filter bank is publicly standardized:
the 64-QMF → 77-hybrid structure, the 13-tap low-band prototypes and their
half-bin complex modulation follow 3GPP TS 26.405 / ETSI TS 126 405 (Section
5.2.2, Table 1, ``Q=8``/``Q=4``); the 64-band QMF analysis is the MPEG-4
AAC/SBR 64 complex QMF analysis bank (ISO/IEC 14496-3/AMD1:2003, subclause
4.B.18.2), stored here as the polyphase form
``A[r,t] = ((-1)**t / 128) * c[63 - r + 64*t]`` of the public 640-tap SBR
prototype. The QMF synthesis table is the causal left inverse of that
analysis polyphase matrix (``A @ W = P`` with the 577-sample delay
permutation; total latency ``961 = 577 + 6*64``), stored as the rank-4
factorization ``W[b,l] = sum_r taps[b,l,r] * basis[b,r,:]``; the hybrid
synthesis table is the 77->64 recombination (identity for the high bands,
signed summation of each 8+4+4 child group for the low bands), stored as a
154-entry sparse map. The archive and every array inside it are
hash-validated before use, and those hashes participate in every
compiled-HRTF cache key. Provenance and rights boundaries are documented in
``data/README.md`` and ``THIRD_PARTY_NOTICES.md``.
"""
from __future__ import annotations
from functools import lru_cache
import hashlib
import os
from pathlib import Path
import zipfile
import numpy as np
PROJECT_DIR = Path(__file__).resolve().parent.parent
DEFAULT_FILTERBANK_DATA = PROJECT_DIR / "data" / "rosella_kernels.npz"
FILTERBANK_TABLE_VERSION = "joc-public-64qmf-77hybrid-v1"
SAMPLE_RATE = 48000
QMF_HOP = 64
QMF_BANDS = 64
HYBRID_BANDS = 77
ANALYSIS_SYNTHESIS_LATENCY_SAMPLES = 961
_ARCHIVE_SHA256 = "C05BEF4D26E96ECBD4694E2572F05DA400255C777BA5047300B9D3B1F81081CD"
_TABLE_SPECS = {
"format_version": (np.dtype("<i4"), (1,),
"67ABDD721024F0FF4E0B3F4C2FC13BC5BAD42D0B7851D456D88D203D15AAA450",
False, False),
"qmf_analysis_coefficients": (
np.dtype("<f4"), (64, 10),
"AEFF6C7117D41664B9C4BF03BBF563F5319EC1B8C551F171ADBB90CF19D9D306",
False, False),
"hybrid_analysis_low_kernel": (
np.dtype("<f4"), (3, 2, 13, 16, 2),
"D00D36133B81BA699A7630C4DF1BE203FA1B7E371E595EAAEBBE8957DB322627",
False, False),
"hybrid_synthesis_indices": (
np.dtype("<i2"), (154, 4),
"F5BEB3220E4530FCF28E7F4DA7F07E821074265D118C911D61A590E00753A573",
True, False),
"hybrid_synthesis_values": (
np.dtype("<f4"), (154,),
"99409FDD9D20D1D7C2BE16BBC1E2159C8042487227C72160850745164C9CEE7F",
False, False),
"qmf_synthesis_basis": (
np.dtype("<f8"), (64, 4, 128),
"A0C4A55385F6D6C7C92D7615C83AD5FBDA51046D9EF785CAC0B9AC9A760DC527",
False, False),
"qmf_synthesis_taps": (
np.dtype("<f8"), (64, 10, 4),
"CD7756D060D51FBF02F44C1CE53CB6225B221099505C94C3F58D3BEE6F428150",
False, False),
}
# Hybrid-band center frequencies measured from the public analysis bank at
# 48 kHz (positive-frequency response peaks). They are part of the validated
# reference behavior: the runtime uses them only for the fractional-delay band
# phase and the project LFE low-pass, never as filterbank coefficients.
_BAND_CENTER_FREQUENCIES_HZ = np.asarray([
53.19564095937407,
26.3876219849709,
140.9074183269806,
98.55238901464415,
234.09258166297573,
344.8745321543293,
321.80435900819805,
401.38762185365727,
476.19239745597804,
473.552388917001,
648.8076026837931,
719.8745321201852,
780.1254679168173,
851.1923973714038,
1023.8076024849751,
1155.125467695814,
1293.0325067063661,
1668.032511377253,
2043.0325074138086,
2456.967490229666,
2831.96748495363,
3206.967488172826,
3581.9675052705525,
3918.0325089666067,
4331.967500201844,
4706.967500350216,
5043.032510771545,
5456.9674914391635,
5831.967490225267,
6168.0324885741875,
6543.032508972284,
6956.96748844665,
7293.032503259869,
7668.032503551393,
8043.032504806491,
8418.032498852166,
8793.032502508235,
9206.967488589786,
9543.032511549152,
9956.967486913867,
10293.032513008677,
10668.032507835102,
11043.032513641429,
11456.967482937946,
11793.032513984212,
12206.967486015788,
12543.032517062376,
12956.96748635822,
13331.967492165066,
13706.967486991198,
14043.032513086031,
14456.967488451,
14793.032511410214,
15206.967497492202,
15581.967501147887,
15956.967495193188,
16331.96749644876,
16706.967496740173,
17043.03251155318,
17456.96749102773,
17831.967511425748,
18168.032509774734,
18543.032508560515,
18956.967489228293,
19293.032499649784,
19668.032499798002,
20081.967491033392,
20418.0324947296,
20793.032511827063,
21168.032515046092,
21543.032509770488,
21956.967492586176,
22331.96748862257,
22706.967493293465,
23043.03251375972,
23418.03251061199,
23831.96749768645,
], dtype=np.float64)
def _sha256_bytes(values: bytes) -> str:
return hashlib.sha256(values).hexdigest().upper()
def _validate_npy_member_header(
archive: zipfile.ZipFile, member: zipfile.ZipInfo,
*, name: str, dtype: np.dtype, shape: tuple[int, ...],
allow_fortran: bool) -> None:
try:
with archive.open(member, "r") as payload:
version = np.lib.format.read_magic(payload)
if version == (1, 0):
actual_shape, actual_fortran_order, actual_dtype = (
np.lib.format.read_array_header_1_0(
payload, max_header_size=4096))
elif version == (2, 0):
actual_shape, actual_fortran_order, actual_dtype = (
np.lib.format.read_array_header_2_0(
payload, max_header_size=4096))
else:
raise ValueError(f"unsupported .npy version {version!r}")
header_size = payload.tell()
except (EOFError, OSError, ValueError) as exc:
raise ValueError(
f"invalid public filterbank table .npy header: {member.filename}: "
f"{exc}") from exc
actual_shape = tuple(actual_shape)
actual_dtype = np.dtype(actual_dtype)
if (actual_shape != shape or actual_dtype != dtype
or (bool(actual_fortran_order) and not allow_fortran)):
expected_order = "C-order" if not allow_fortran else "C- or Fortran-order"
actual_order = "Fortran-order" if actual_fortran_order else "C-order"
raise ValueError(
f"invalid public filterbank table .npy header for {name}: expected "
f"{dtype}{shape} {expected_order}, got "
f"{actual_dtype}{actual_shape} {actual_order}")
expected_size = header_size + dtype.itemsize * int(np.prod(shape))
if member.file_size != expected_size:
raise ValueError(
f"invalid public filterbank table .npy payload size for {name}: "
f"expected {expected_size} bytes including the header, "
f"got {member.file_size}")
def _validate_table_members(stream) -> None:
expected = {name + ".npy" for name in _TABLE_SPECS}
limits = {
name + ".npy": dtype.itemsize * int(np.prod(shape)) + 4096
for name, (dtype, shape, _, _, _) in _TABLE_SPECS.items()
}
try:
stream.seek(0)
with zipfile.ZipFile(stream, "r") as archive:
members = archive.infolist()
if (len(members) != len(expected)
or {member.filename for member in members} != expected):
raise ValueError(
"public filterbank table archive has an invalid member set")
for member in members:
if member.flag_bits & 0x1:
raise ValueError("encrypted public filterbank tables are unsupported")
if member.compress_type not in (
zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
raise ValueError("unsupported public filterbank table compression")
if member.file_size > limits[member.filename]:
raise ValueError(
f"public filterbank table member is unexpectedly large: "
f"{member.filename}")
if sum(member.file_size for member in members) > sum(limits.values()):
raise ValueError("public filterbank tables expand beyond their size limit")
for member in members:
name = member.filename[:-4]
dtype, shape, _, allow_fortran, _ = _TABLE_SPECS[name]
_validate_npy_member_header(
archive, member, name=name, dtype=dtype, shape=shape,
allow_fortran=allow_fortran)
except zipfile.BadZipFile as exc:
raise ValueError(f"invalid public filterbank table archive: {exc}") from exc
@lru_cache(maxsize=2)
def _load_tables(path_string: str) -> dict[str, np.ndarray]:
path = Path(path_string)
if not path.is_file():
raise FileNotFoundError(f"public filterbank table resource not found: {path}")
with path.open("rb") as stream:
archive_size = os.fstat(stream.fileno()).st_size
if archive_size <= 0 or archive_size > 8 << 20:
raise ValueError(
f"public filterbank table resource is unexpectedly large: {path}")
if path.resolve() == DEFAULT_FILTERBANK_DATA.resolve():
digest = hashlib.sha256()
for block in iter(lambda: stream.read(4 << 20), b""):
digest.update(block)
actual_archive_hash = digest.hexdigest().upper()
if actual_archive_hash != _ARCHIVE_SHA256:
raise ValueError(
"public filterbank table archive hash mismatch: "
f"expected {_ARCHIVE_SHA256}, got {actual_archive_hash}")
_validate_table_members(stream)
stream.seek(0)
with np.load(stream, allow_pickle=False) as archive:
if set(archive.files) != set(_TABLE_SPECS):
raise ValueError("public filterbank table archive has an invalid key set")
result: dict[str, np.ndarray] = {}
for name, (dtype, shape, expected_hash, _, _) in _TABLE_SPECS.items():
value = np.asarray(archive[name])
if value.dtype != dtype or value.shape != shape:
raise ValueError(
f"invalid public filterbank table {name}: "
f"expected {dtype}{shape}, got {value.dtype}{value.shape}")
actual_hash = _sha256_bytes(value.tobytes(order="C"))
if actual_hash != expected_hash:
raise ValueError(f"public filterbank table hash mismatch: {name}")
result[name] = np.ascontiguousarray(value)
result[name].setflags(write=False)
if int(result["format_version"][0]) != 1:
raise ValueError("unsupported public filterbank table format version")
return result
def load_filterbank_tables(
path: str | Path = DEFAULT_FILTERBANK_DATA) -> dict[str, np.ndarray]:
"""Load the validated project resource used by the public filterbank."""
cached = _load_tables(str(Path(path).expanduser().resolve()))
result = {name: value.copy() for name, value in cached.items()}
for value in result.values():
value.setflags(write=False)
return result
def filterbank_fingerprint() -> dict:
"""Return stable identifiers used in compiled-HRTF cache keys."""
centers = np.ascontiguousarray(_BAND_CENTER_FREQUENCIES_HZ, dtype="<f8")
return {
"table_version": FILTERBANK_TABLE_VERSION,
"archive_sha256": _ARCHIVE_SHA256,
"band_centers_sha256": _sha256_bytes(centers.tobytes(order="C")),
"array_sha256": {
name: spec[2] for name, spec in _TABLE_SPECS.items()
},
}
class QmfAnalysis:
"""Batchable 64-band analysis with float64 state and complex128 FFTs."""
def __init__(self, channels: int,
table_data: str | Path = DEFAULT_FILTERBANK_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_filterbank_tables(table_data)
self.coefficients = np.asarray(
tables["qmf_analysis_coefficients"], dtype=np.float64)
self.channels = int(channels)
self.history = np.zeros((9, self.channels, 64), dtype=np.float64)
phase = np.arange(64, dtype=np.float64)
self.premod = np.exp(-1j * np.pi * phase / 128.0).astype(np.complex128)
self.post = np.exp(
-1j * 3.0 * (np.arange(64, dtype=np.float64) + 0.5) * np.pi / 128.0
).astype(np.complex128)
self.even_post = (
1j * ((-1.0) ** np.arange(64, dtype=np.float64))
).astype(np.complex128)
def reset(self) -> None:
self.history.fill(0.0)
def process_chunk(self, hops) -> np.ndarray:
values = np.asarray(hops, dtype=np.float64)
if values.ndim != 3 or values.shape[1:] != (self.channels, 64):
raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}")
if not np.isfinite(values).all():
raise ValueError("QMF input contains non-finite values")
count = values.shape[0]
joined = np.concatenate((self.history, values), axis=0)
even = np.zeros_like(values)
odd = np.zeros_like(values)
for lag in range(10):
source = joined[9 - lag:9 - lag + count]
target = even if lag % 2 == 0 else odd
target += source * self.coefficients[:, lag][None, None, :]
self.history[:] = joined[-9:]
def transform(block):
prepared = block.astype(np.complex128, copy=False) * self.premod
transformed = np.fft.fft(prepared, n=128, axis=-1)[..., :64]
return transformed * self.post
return np.asarray(transform(odd) + transform(even) * self.even_post,
dtype=np.complex128)
class HybridAnalysis:
"""Sparse 64-QMF to 77-hybrid analysis in float64/complex128."""
def __init__(self, channels: int,
table_data: str | Path = DEFAULT_FILTERBANK_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_filterbank_tables(table_data)
self.low_kernel = np.asarray(
tables["hybrid_analysis_low_kernel"], dtype=np.float64)
self.channels = int(channels)
self.history = np.zeros((12, self.channels, 3, 2), dtype=np.float64)
self.high_history = np.zeros(
(6, self.channels, 61), dtype=np.complex128)
def reset(self) -> None:
self.history.fill(0.0)
self.high_history.fill(0.0)
def process_chunk(self, qmf) -> np.ndarray:
values = np.asarray(qmf, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, 64):
raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}")
if not np.isfinite(values).all():
raise ValueError("hybrid-analysis input contains non-finite values")
count = values.shape[0]
low = np.stack((values[:, :, :3].real, values[:, :, :3].imag), axis=-1)
joined = np.concatenate((self.history, low), axis=0)
output = np.zeros((count, self.channels, 77, 2), dtype=np.float64)
for lag in range(13):
source = joined[12 - lag:12 - lag + count]
output[:, :, :16] += np.einsum(
"tcpi,pibo->tcbo", source, self.low_kernel[:, :, lag],
dtype=np.float64, optimize=False)
self.history[:] = joined[-12:]
high_joined = np.concatenate((self.high_history, values[:, :, 3:]), axis=0)
high = high_joined[:count]
output[:, :, 16:, 0] = high.real
output[:, :, 16:, 1] = high.imag
self.high_history[:] = high_joined[-6:]
return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128)
class HybridSynthesis:
"""Instantaneous sparse 77-hybrid to 64-QMF synthesis map."""
def __init__(self, channels: int,
table_data: str | Path = DEFAULT_FILTERBANK_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_filterbank_tables(table_data)
indices = np.asarray(tables["hybrid_synthesis_indices"], dtype=np.int64)
values = np.asarray(tables["hybrid_synthesis_values"], dtype=np.float64)
if indices.ndim != 2 or indices.shape[1] != 4 or len(indices) != len(values):
raise ValueError("invalid hybrid synthesis sparse table")
self.mapping = [
(int(index[0]), int(index[1]), int(index[2]), int(index[3]), float(value))
for index, value in zip(indices, values)
]
self.channels = int(channels)
def reset(self) -> None:
return None
def process_chunk(self, hybrid) -> np.ndarray:
values = np.asarray(hybrid, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, 77):
raise ValueError(f"expected [slots,{self.channels},77], got {values.shape}")
if not np.isfinite(values).all():
raise ValueError("hybrid-synthesis input contains non-finite values")
source = np.stack((values.real, values.imag), axis=-1)
output = np.zeros((values.shape[0], self.channels, 64, 2), dtype=np.float64)
for input_band, input_component, output_band, output_component, gain in self.mapping:
output[:, :, output_band, output_component] += (
source[:, :, input_band, input_component] * gain)
return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128)
class QmfSynthesis:
"""Rank-4 64-band synthesis with float64 state and accumulation."""
def __init__(self, channels: int,
table_data: str | Path = DEFAULT_FILTERBANK_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_filterbank_tables(table_data)
self.basis = np.asarray(tables["qmf_synthesis_basis"], dtype=np.float64)
self.taps = np.asarray(tables["qmf_synthesis_taps"], dtype=np.float64)
if self.basis.shape != (64, 4, 128) or self.taps.shape != (64, 10, 4):
raise ValueError("invalid QMF synthesis factorization")
self.channels = int(channels)
self.rank = 4
self.history = np.zeros(
(9, self.channels, 64, self.rank), dtype=np.float64)
def reset(self) -> None:
self.history.fill(0.0)
def process_chunk(self, qmf) -> np.ndarray:
values = np.asarray(qmf, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, 64):
raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}")
if not np.isfinite(values).all():
raise ValueError("QMF-synthesis input contains non-finite values")
count = values.shape[0]
flat = np.stack((values.real, values.imag), axis=-1).reshape(
count * self.channels, 128)
modulation = self.basis.reshape(64 * self.rank, 128)
features = (flat @ modulation.T).reshape(
count, self.channels, 64, self.rank)
joined = np.concatenate((self.history, features), axis=0)
output = np.zeros((count, self.channels, 64), dtype=np.float64)
for lag in range(10):
output += np.sum(
joined[9 - lag:9 - lag + count]
* self.taps[:, lag, :][None, None, :, :],
axis=-1, dtype=np.float64)
self.history[:] = joined[-9:]
return output
class PublicAnalysis77:
"""Full-rate PCM to the public 77-band hybrid representation."""
def __init__(self, channels: int):
self.channels = int(channels)
if self.channels <= 0:
raise ValueError("channels must be positive")
self.qmf = QmfAnalysis(self.channels)
self.hybrid = HybridAnalysis(self.channels)
def reset(self) -> None:
self.qmf.reset()
self.hybrid.reset()
def process(self, samples) -> np.ndarray:
values = np.asarray(samples, dtype=np.float64)
if values.ndim == 1 and self.channels == 1:
values = values[:, None]
if values.ndim != 2 or values.shape[1] != self.channels:
raise ValueError(f"samples must have shape [N,{self.channels}]")
if len(values) % QMF_HOP:
raise ValueError("sample count must be divisible by the 64-sample QMF hop")
if not np.isfinite(values).all():
raise ValueError("samples contain non-finite values")
hops = values.reshape(-1, QMF_HOP, self.channels).transpose(0, 2, 1)
return np.asarray(
self.hybrid.process_chunk(self.qmf.process_chunk(hops)),
dtype=np.complex128)
class PublicSynthesis77:
"""Public 77-band hybrid representation to full-rate PCM."""
def __init__(self, channels: int):
self.channels = int(channels)
if self.channels <= 0:
raise ValueError("channels must be positive")
self.hybrid = HybridSynthesis(self.channels)
self.qmf = QmfSynthesis(self.channels)
def reset(self) -> None:
self.hybrid.reset()
self.qmf.reset()
def process(self, hybrid) -> np.ndarray:
values = np.asarray(hybrid, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, HYBRID_BANDS):
raise ValueError(
f"hybrid must have shape [slots,{self.channels},{HYBRID_BANDS}]")
if not np.isfinite(values).all():
raise ValueError("hybrid input contains non-finite values")
qmf = self.hybrid.process_chunk(values)
time = self.qmf.process_chunk(qmf)
return np.asarray(time.transpose(0, 2, 1).reshape(-1, self.channels),
dtype=np.float64)
def identity_impulse_response(sample_count: int = 4096) -> np.ndarray:
sample_count = int(sample_count)
if sample_count <= 0:
raise ValueError("sample_count must be positive")
total = ((sample_count + QMF_HOP - 1) // QMF_HOP) * QMF_HOP
impulse = np.zeros((total, 1), dtype=np.float64)
impulse[0, 0] = 1.0
analysis = PublicAnalysis77(1)
synthesis = PublicSynthesis77(1)
return synthesis.process(analysis.process(impulse))[:, 0]
@lru_cache(maxsize=2)
def _hybrid_band_center_frequencies_hz_cached(rate: float) -> np.ndarray:
centers = np.asarray(
_BAND_CENTER_FREQUENCIES_HZ * (rate / SAMPLE_RATE), dtype=np.float64)
centers.setflags(write=False)
return centers
def hybrid_band_center_frequencies_hz(
sample_rate_hz: float = SAMPLE_RATE) -> np.ndarray:
"""Return the 77 hybrid-band reference center frequencies."""
rate = float(sample_rate_hz)
if not np.isfinite(rate) or rate <= 0.0:
raise ValueError("sample rate must be positive and finite")
centers = _hybrid_band_center_frequencies_hz_cached(rate).copy()
centers.setflags(write=False)
return centers
def table_info() -> dict:
return {
"resource": DEFAULT_FILTERBANK_DATA.name,
"sample_rate_hz": SAMPLE_RATE,
"qmf_bands": QMF_BANDS,
"hybrid_bands": HYBRID_BANDS,
"hop_samples": QMF_HOP,
"analysis_synthesis_latency_samples": ANALYSIS_SYNTHESIS_LATENCY_SAMPLES,
"precision": "float64/complex128",
"fingerprint": filterbank_fingerprint(),
"provenance": {
"qmf": (
"MPEG-4 AAC/SBR 64 complex QMF analysis (ISO/IEC "
"14496-3/AMD1:2003 4.B.18.2), polyphase form of the public "
"640-tap SBR prototype"),
"hybrid": (
"3GPP TS 26.405 / ETSI TS 126 405 5.2.2 Table 1 (Q=8/Q=4) "
"with standard half-bin complex modulation"),
"synthesis": (
"causal left inverse of the public analysis bank "
"(A·W = P, 577-sample QMF delay); 77→64 sparse recombination"),
"resource": "data/rosella_kernels.npz",
},
}
@lru_cache(maxsize=8)
def _hybrid_gain_synthesis_dictionary_cached(count: int) -> np.ndarray:
total = int(np.ceil(
(ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + count + 512) / QMF_HOP) * QMF_HOP)
impulse = np.zeros((total, 1), dtype=np.float64)
impulse[0, 0] = 1.0
base = PublicAnalysis77(1).process(impulse)[:, 0, :]
parameter_count = 2 * HYBRID_BANDS
hybrid = np.zeros(
(len(base), parameter_count, HYBRID_BANDS), dtype=np.complex128)
for band in range(HYBRID_BANDS):
hybrid[:, 2 * band, band] = base[:, band]
hybrid[:, 2 * band + 1, band] = 1j * base[:, band]
rendered = PublicSynthesis77(parameter_count).process(hybrid)
start = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
dictionary = np.asarray(
rendered[start:start + count], dtype=np.float64).copy()
dictionary.setflags(write=False)
return dictionary
def hybrid_gain_synthesis_dictionary(sample_count: int) -> np.ndarray:
"""Return the 154-real-parameter analysis/gain/synthesis dictionary.
Each hybrid band contributes one real-gain and one imaginary-gain column.
The common 961-sample filterbank latency is removed from every column.
"""
count = int(sample_count)
if count <= 0:
raise ValueError("sample_count must be positive")
dictionary = _hybrid_gain_synthesis_dictionary_cached(count).copy()
dictionary.setflags(write=False)
return dictionary
def project_hrir_to_hybrid_gains(
hrir, *, embedded_delay_samples=None,
sample_rate_hz: float = SAMPLE_RATE, ridge: float = 1.0e-3,
) -> tuple[np.ndarray, dict]:
"""Project FIRs and remove only a known embedded arrival delay.
Non-zero SOFA ``Data.Delay`` is external and must be passed as zero here.
A positive onset separated from ``Data.IR`` is de-rotated once, then restored
once by the runtime field. A zero-origin FIR keeps its authored complex
phase and therefore also passes zero.
"""
values = np.asarray(hrir, dtype=np.float64)
if values.ndim != 3 or values.shape[1] != 2 or values.shape[2] <= 0:
raise ValueError("hrir must have shape [M,2,N]")
if not np.isfinite(values).all():
raise ValueError("hrir contains non-finite values")
regularization = float(ridge)
if not np.isfinite(regularization) or regularization < 0.0:
raise ValueError("projection ridge must be finite and non-negative")
if embedded_delay_samples is None:
delay = np.zeros(values.shape[:2], dtype=np.float64)
else:
delay = np.asarray(embedded_delay_samples, dtype=np.float64)
if delay.shape != values.shape[:2] or not np.isfinite(delay).all():
raise ValueError("embedded_delay_samples must have finite shape [M,2]")
rate = float(sample_rate_hz)
if not np.isfinite(rate) or rate <= 0.0:
raise ValueError("sample_rate_hz must be positive and finite")
dictionary = _hybrid_gain_synthesis_dictionary_cached(values.shape[2])
gram = dictionary.T @ dictionary
scale = float(np.trace(gram)) / gram.shape[0]
system = gram + regularization * scale * np.eye(gram.shape[0], dtype=np.float64)
target = values.reshape(-1, values.shape[2]).T
parameters = np.linalg.solve(system, dictionary.T @ target).T
parts = parameters.reshape(values.shape[0], 2, 2 * HYBRID_BANDS)
transfer = np.asarray(parts[..., 0::2] + 1j * parts[..., 1::2],
dtype=np.complex128)
centers = hybrid_band_center_frequencies_hz(rate)
removal_phase = np.exp(
2j * np.pi * delay[..., None] * centers[None, None, :] / rate)
aligned = np.asarray(transfer * removal_phase, dtype=np.complex128)
reconstructed = dictionary @ parameters.T
error = target - reconstructed
reference_energy = np.sum(target * target, axis=0, dtype=np.float64)
error_energy = np.sum(error * error, axis=0, dtype=np.float64)
snr = 10.0 * np.log10(
np.maximum(reference_energy, 1.0e-300)
/ np.maximum(error_energy, 1.0e-300))
report = {
"method": "regularized public analysis/gain/synthesis dictionary",
"dictionary_shape": list(dictionary.shape),
"real_parameters": 2 * HYBRID_BANDS,
"ridge": regularization,
"embedded_delay_samples_min": float(np.min(delay)),
"embedded_delay_samples_max": float(np.max(delay)),
"fir_reconstruction_snr_db_median": float(np.median(snr)),
"fir_reconstruction_snr_db_p05": float(np.percentile(snr, 5.0)),
"fir_reconstruction_snr_db_min": float(np.min(snr)),
"maximum_absolute_hybrid_gain": float(np.max(np.abs(aligned))),
"precision": "float64/complex128",
}
return aligned, report
def project_aligned_hrir_to_hybrid_gains(
aligned_hrir, *, ridge: float = 1.0e-3) -> tuple[np.ndarray, dict]:
return project_hrir_to_hybrid_gains(aligned_hrir, ridge=ridge)
+253
View File
@@ -0,0 +1,253 @@
"""Project-owned image-source early reflections and shared unitary FDN."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
@dataclass(frozen=True)
class ShoeboxRoomConfig:
dimensions_m: tuple[float, float, float] = (18.0, 18.0, 14.0)
listener_position_m: tuple[float, float, float] = (9.0, 9.0, 7.0)
wall_reflection_gain: tuple[float, float, float, float, float, float] = (
0.62, 0.60, 0.58, 0.61, 0.52, 0.56)
speed_of_sound_m_s: float = 343.3
def validate(self) -> None:
dimensions = np.asarray(self.dimensions_m, dtype=np.float64)
listener = np.asarray(self.listener_position_m, dtype=np.float64)
gains = np.asarray(self.wall_reflection_gain, dtype=np.float64)
if (dimensions.shape != (3,) or not np.isfinite(dimensions).all()
or np.any(dimensions <= 0.0)):
raise ValueError("room dimensions must be three positive finite values")
if (listener.shape != (3,) or not np.isfinite(listener).all()
or np.any(listener <= 0.0) or np.any(listener >= dimensions)):
raise ValueError("listener must be strictly inside the shoebox")
if (gains.shape != (6,) or not np.isfinite(gains).all()
or np.any(np.abs(gains) >= 1.0)):
raise ValueError("six finite wall gains must have magnitude below one")
if not math.isfinite(self.speed_of_sound_m_s) or self.speed_of_sound_m_s <= 0.0:
raise ValueError("speed of sound must be positive")
@dataclass(frozen=True)
class EarlyReflection:
wall: str
direction_adm: np.ndarray
path_distance_m: float
extra_delay_samples: float
reflection_gain: float
_WALL_NAMES = ("left", "right", "back", "front", "floor", "ceiling")
def first_order_image_sources(direction_adm, source_distance_m: float,
sample_rate_hz: float,
config: ShoeboxRoomConfig = ShoeboxRoomConfig()
) -> tuple[EarlyReflection, ...]:
"""Return six first-order image-source paths for one object."""
config.validate()
direction = np.asarray(direction_adm, dtype=np.float64)
if direction.shape != (3,) or not np.isfinite(direction).all():
raise ValueError("reflection direction must contain three finite ADM values")
norm = float(np.linalg.norm(direction))
if norm <= 1.0e-15:
direction = np.asarray([0.0, 1.0, 0.0], dtype=np.float64)
else:
direction = direction / norm
distance = float(source_distance_m)
rate = float(sample_rate_hz)
if not math.isfinite(distance) or distance <= 0.0 or not math.isfinite(rate) or rate <= 0.0:
raise ValueError("source distance and sample rate must be positive")
dimensions = np.asarray(config.dimensions_m, dtype=np.float64)
listener = np.asarray(config.listener_position_m, dtype=np.float64)
source = listener + direction * distance
if np.any(source <= 0.0) or np.any(source >= dimensions):
raise ValueError(
"source lies outside the configured public shoebox; enlarge the room")
images = []
for axis in range(3):
low = source.copy()
low[axis] = -source[axis]
high = source.copy()
high[axis] = 2.0 * dimensions[axis] - source[axis]
images.extend((low, high))
result = []
for wall, image, gain in zip(_WALL_NAMES, images, config.wall_reflection_gain):
vector = image - listener
path_distance = float(np.linalg.norm(vector))
path_direction = vector / path_distance
extra = max(0.0, (path_distance - distance)
* rate / config.speed_of_sound_m_s)
air = math.exp(-0.002 * max(path_distance - distance, 0.0))
result.append(EarlyReflection(
wall=wall,
direction_adm=np.asarray(path_direction, dtype=np.float64),
path_distance_m=path_distance,
extra_delay_samples=extra,
reflection_gain=float(gain) * air,
))
return tuple(result)
def normalized_hadamard4() -> np.ndarray:
return 0.5 * np.asarray([
[1.0, 1.0, 1.0, 1.0],
[1.0, -1.0, 1.0, -1.0],
[1.0, 1.0, -1.0, -1.0],
[1.0, -1.0, -1.0, 1.0],
], dtype=np.float64)
def _is_prime(value: int) -> bool:
if value < 2:
return False
if value % 2 == 0:
return value == 2
limit = int(math.sqrt(value))
return all(value % divisor for divisor in range(3, limit + 1, 2))
def _next_prime(value: int) -> int:
candidate = max(2, int(value))
while not _is_prime(candidate):
candidate += 1
return candidate
class SchroederAllpass:
def __init__(self, delay_samples: int, gain: float):
self.delay_samples = int(delay_samples)
self.gain = float(gain)
if self.delay_samples <= 0 or not 0.0 <= abs(self.gain) < 1.0:
raise ValueError("all-pass delay must be positive and |gain| < 1")
self.buffer = np.zeros(self.delay_samples, dtype=np.float64)
self.position = 0
def reset(self) -> None:
self.buffer.fill(0.0)
self.position = 0
def process(self, values) -> np.ndarray:
source = np.asarray(values, dtype=np.float64)
output = np.empty_like(source)
for index, value in enumerate(source):
delayed = self.buffer[self.position]
result = delayed - self.gain * value
self.buffer[self.position] = value + self.gain * result
self.position = (self.position + 1) % self.delay_samples
output[index] = result
return output
@dataclass(frozen=True)
class LateFdnConfig:
sample_rate_hz: float = 48000.0
rt60_seconds: float = 0.85
damping: float = 0.32
output_gain: float = 0.22
delay_seconds: tuple[float, float, float, float] = (
0.0297, 0.0371, 0.0411, 0.0437)
allpass_seconds: tuple[float, float] = (0.0023, 0.0067)
allpass_gain: tuple[float, float] = (0.63, 0.51)
class SharedUnitaryFdn:
"""One shared late room driven by the sum of all object room sends."""
def __init__(self, config: LateFdnConfig = LateFdnConfig()):
self.config = config
self.sample_rate_hz = float(config.sample_rate_hz)
self.rt60_seconds = float(config.rt60_seconds)
self.damping = float(config.damping)
self.output_gain = float(config.output_gain)
if (not math.isfinite(self.sample_rate_hz) or self.sample_rate_hz <= 0.0
or not math.isfinite(self.rt60_seconds) or self.rt60_seconds <= 0.0):
raise ValueError("FDN sample rate and RT60 must be positive and finite")
if (not math.isfinite(self.damping) or not 0.0 <= self.damping < 1.0
or not math.isfinite(self.output_gain)):
raise ValueError("invalid FDN damping/output gain")
delay_seconds = np.asarray(config.delay_seconds, dtype=np.float64)
allpass_seconds = np.asarray(config.allpass_seconds, dtype=np.float64)
allpass_gain = np.asarray(config.allpass_gain, dtype=np.float64)
if (delay_seconds.shape != (4,) or not np.isfinite(delay_seconds).all()
or np.any(delay_seconds <= 0.0)):
raise ValueError("FDN requires four positive finite delay times")
if (allpass_seconds.shape != (2,) or not np.isfinite(allpass_seconds).all()
or np.any(allpass_seconds <= 0.0)):
raise ValueError("FDN requires two positive finite all-pass delay times")
if (allpass_gain.shape != (2,) or not np.isfinite(allpass_gain).all()
or np.any(np.abs(allpass_gain) >= 1.0)):
raise ValueError("FDN requires two finite all-pass gains with magnitude below one")
self.matrix = normalized_hadamard4()
self.delays = np.asarray([
_next_prime(round(seconds * self.sample_rate_hz))
for seconds in delay_seconds
], dtype=np.int32)
self.feedback_gain = np.power(
10.0, -3.0 * self.delays / (self.rt60_seconds * self.sample_rate_hz)
).astype(np.float64)
self.buffers = [np.zeros(int(delay), dtype=np.float64) for delay in self.delays]
self.positions = np.zeros(4, dtype=np.int32)
self.damping_state = np.zeros(4, dtype=np.float64)
self.input_vector = 0.5 * np.asarray([1.0, -1.0, 1.0, 1.0], dtype=np.float64)
self.output_matrix = 0.5 * np.asarray([
[1.0, 1.0, -1.0, -1.0],
[1.0, -1.0, 1.0, -1.0],
], dtype=np.float64)
self.diffusers = [
SchroederAllpass(
_next_prime(round(seconds * self.sample_rate_hz)), gain)
for seconds, gain in zip(allpass_seconds, allpass_gain)
]
@property
def tail_samples(self) -> int:
return int(math.ceil(1.5 * self.rt60_seconds * self.sample_rate_hz))
def reset(self) -> None:
for buffer in self.buffers:
buffer.fill(0.0)
self.positions.fill(0)
self.damping_state.fill(0.0)
for diffuser in self.diffusers:
diffuser.reset()
def process(self, mono) -> np.ndarray:
values = np.asarray(mono, dtype=np.float64)
if values.ndim != 1 or not np.isfinite(values).all():
raise ValueError("FDN input must be one finite mono vector")
diffused = values
for diffuser in self.diffusers:
diffused = diffuser.process(diffused)
output = np.empty((len(values), 2), dtype=np.float64)
for sample, value in enumerate(diffused):
delayed = np.asarray([
self.buffers[line][int(self.positions[line])]
for line in range(4)
], dtype=np.float64)
self.damping_state = (
self.damping * self.damping_state + (1.0 - self.damping) * delayed)
output[sample] = self.output_gain * (self.output_matrix @ self.damping_state)
feedback = self.matrix @ (self.damping_state * self.feedback_gain)
write = self.input_vector * value + feedback
for line in range(4):
position = int(self.positions[line])
self.buffers[line][position] = write[line]
self.positions[line] = (position + 1) % int(self.delays[line])
return output
def info(self) -> dict:
return {
"name": "SharedUnitaryFdn",
"sample_rate_hz": self.sample_rate_hz,
"rt60_seconds": self.rt60_seconds,
"delay_samples": [int(value) for value in self.delays],
"feedback_gain": [float(value) for value in self.feedback_gain],
"matrix_unitarity_max_error": float(
np.max(np.abs(self.matrix.T @ self.matrix - np.eye(4)))),
"allpass_delay_samples": [value.delay_samples for value in self.diffusers],
"precision": "float64",
}
+115
View File
@@ -0,0 +1,115 @@
"""Project-owned distance policy for the public SOFA renderer."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
@dataclass(frozen=True)
class DistanceState:
profile: str
normalized_radius: float
reference_distance_m: float
physical_distance_m: float
direction_adm: np.ndarray
class ReferenceDistanceProfileV1:
"""Reference behavior, not a claim about any external public standard."""
DISTANCE_M = {
"near": 1.00000465,
"mid": 2.19327927,
"far": 6.40177584,
}
MINIMUM_DISTANCE_M = 0.10
# Distance profiles in the reference renderer are presentation presets,
# not an instruction to attenuate already-authored programme PCM by 1/r.
# Use an energy-normalized dry/room crossfade instead. The coefficient is
# an explicit project calibration target.
ROOM_ENERGY_COUPLING_PER_M2 = 0.01318359375
PUBLIC_ROOM_CALIBRATION_GAIN = 1.4
# Public, project-owned room coupling; it is not a SOFA or Dolby constant.
LATE_SEND = {
"near": 0.06,
"mid": 0.16,
"far": 0.28,
}
@classmethod
def validate_profile(cls, profile: str) -> str:
value = str(profile).strip().lower()
if value not in cls.DISTANCE_M:
raise ValueError("distance profile must be near, mid, or far")
return value
@classmethod
def map_adm_position(cls, position, profile: str) -> DistanceState:
name = cls.validate_profile(profile)
values = np.asarray(position, dtype=np.float64)
if values.shape != (3,) or not np.isfinite(values).all():
raise ValueError("ADM position must contain three finite Cartesian values")
radius = float(np.linalg.norm(values))
direction = (values / radius if radius > 1.0e-15
else np.asarray([0.0, 1.0, 0.0], dtype=np.float64))
reference = float(cls.DISTANCE_M[name])
distance = max(float(cls.MINIMUM_DISTANCE_M), radius * reference)
return DistanceState(
profile=name,
normalized_radius=radius,
reference_distance_m=reference,
physical_distance_m=distance,
direction_adm=np.asarray(direction, dtype=np.float64),
)
@staticmethod
def inverse_distance_gain(measurement_radius_m: float,
path_distance_m: float) -> float:
radius = float(measurement_radius_m)
distance = float(path_distance_m)
if not (math.isfinite(radius) and math.isfinite(distance)):
raise ValueError("measurement and path distances must be finite")
if radius <= 0.0 or distance <= 0.0:
raise ValueError("measurement and path distances must be positive")
return radius / distance
@classmethod
def direct_level_gain(cls, state: DistanceState) -> float:
"""Programme-normalized direct level for a distance presentation.
Near is the SOFA reference response. Mid/Far use an equal-power dry
coefficient rather than a physical free-field 1/r attenuation. Room
distance still changes through image-path lengths and late send.
"""
if state.profile == "near":
return 1.0
distance = float(state.physical_distance_m)
return 1.0 / math.sqrt(
1.0 + cls.ROOM_ENERGY_COUPLING_PER_M2 * distance * distance)
@classmethod
def room_calibration_gain(cls, state: DistanceState) -> float:
del state
return float(cls.PUBLIC_ROOM_CALIBRATION_GAIN)
@classmethod
def late_send(cls, state: DistanceState) -> float:
base = float(cls.LATE_SEND[state.profile])
radial = math.sqrt(max(state.normalized_radius, 0.0))
return base * min(max(radial, 0.25), 1.5)
@classmethod
def info(cls) -> dict:
return {
"name": "ReferenceDistanceProfileV1",
"reference_distance_m": dict(cls.DISTANCE_M),
"minimum_distance_m": cls.MINIMUM_DISTANCE_M,
"direct_level_policy": (
"Near unity; Mid/Far equal-power dry coefficient, never raw 1/r "
"programme attenuation"),
"room_energy_coupling_per_m2": cls.ROOM_ENERGY_COUPLING_PER_M2,
"public_room_calibration_gain": cls.PUBLIC_ROOM_CALIBRATION_GAIN,
"late_send": dict(cls.LATE_SEND),
"standard_claim": False,
}
+308
View File
@@ -0,0 +1,308 @@
"""Rosella .personalized_headphone binaural renderer.
Rosella JSON 解析由本项目自行实现(src/rosella_model.py),不调用任何 Dolby
软件;.personalized_headphone 是用户经官方软件个性化扫描得到的模型文件。
该路径与 SOFA 路径各自独立完成 HRTF/room 参数求值,只在最外层的 JOC 调度
(1536-sample 帧缓冲、sample-timed OAMD timeline、512-sample 参数更新、输出
包装)处汇合。
"""
from __future__ import annotations
import hashlib
import math
from pathlib import Path
import numpy as np
from binaural_metadata import OamdPositionTimeline
from binaural_native_renderer import NativeBinauralDsp
from rosella_core import RosellaRenderer
from rosella_direct import BINAURAL_PROFILE_NAMES
from rosella_filterbank import (
DEFAULT_KERNEL_DATA,
HybridAnalysis,
HybridSynthesis,
QmfAnalysis,
QmfSynthesis,
)
from rosella_model import RosellaModel, load_personalized_headphone
SAMPLE_RATE = 48000
FRAME_SAMPLES = 1536
ROSSELLA_BLOCK_SAMPLES = 512
QMF_HOP_SAMPLES = 64
ROSSELLA_LATENCY_SAMPLES = 961
SOURCE_CHANNELS = 16
OUTPUT_CHANNELS = 2
PROJECT_DIR = Path(__file__).resolve().parent.parent
DEFAULT_PERSONALIZED_HEADPHONE = (
PROJECT_DIR / "HRTF" / "binaural.personalized_headphone")
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def resolve_personalized_headphone(path: str | Path | None = None) -> Path:
target = (DEFAULT_PERSONALIZED_HEADPHONE if path is None
else Path(path).expanduser().resolve())
if not target.is_file():
raise FileNotFoundError(
f"未找到双耳模型:{target}\n"
"请将兼容模型保存为 HRTF/binaural.personalized_headphone,"
"或通过参数指定文件。"
)
return target
class RosellaBinauralRenderer:
"""Render interleaved LFE plus fifteen objects to stereo."""
def __init__(
self,
personalized_headphone: str | Path | RosellaModel,
*,
mode: str = "mid",
kernel_data: str | Path = DEFAULT_KERNEL_DATA,
object_delay_samples: int = 1473,
tail_seconds: float = 5.0,
output_gain: float = 1.0,
chunk_frames: int = 64,
room_impulse_slots: int = 4096,
backend: str = "python",
native_library=None):
if mode not in BINAURAL_PROFILE_NAMES:
raise ValueError("binaural mode must be near, mid, or far")
if int(object_delay_samples) < 0:
raise ValueError("object_delay_samples must be non-negative")
if float(tail_seconds) < 0.0:
raise ValueError("tail_seconds must be non-negative")
if int(chunk_frames) <= 0:
raise ValueError("chunk_frames must be positive")
if not math.isfinite(float(output_gain)):
raise ValueError("output_gain must be finite")
if backend not in ("auto", "native", "python"):
raise ValueError("backend must be auto, native, or python")
if isinstance(personalized_headphone, RosellaModel):
self.model = personalized_headphone
self.model_path = Path(self.model.source_path)
else:
self.model_path = resolve_personalized_headphone(personalized_headphone)
self.model = load_personalized_headphone(self.model_path)
if self.model.sample_rate != SAMPLE_RATE:
raise ValueError(
f"Rosella model sample rate must be {SAMPLE_RATE}, got {self.model.sample_rate}")
self.mode = mode
self.profile_index = BINAURAL_PROFILE_NAMES[mode]
self.kernel_data = Path(kernel_data).expanduser().resolve()
self.kernel_data_sha256 = _sha256_file(self.kernel_data)
self.object_delay_samples = int(object_delay_samples)
self.tail_seconds = float(tail_seconds)
self.output_gain = np.float64(output_gain)
self.chunk_frames = int(chunk_frames)
self.chunk_samples = self.chunk_frames * FRAME_SAMPLES
self.native_dsp = None
self.backend_fallback = None
if backend in ("auto", "native"):
try:
self.native_dsp = NativeBinauralDsp(
self.model, library_path=native_library,
kernel_data=self.kernel_data)
except (AttributeError, OSError, RuntimeError) as exc:
if backend == "native":
raise RuntimeError(f"native binaural backend unavailable: {exc}") from exc
self.backend_fallback = str(exc)
if self.native_dsp is not None:
self.dsp_backend = "native"
self.qmf_analysis = None
self.hybrid_analysis = None
self.hybrid_synthesis = None
self.qmf_synthesis = None
self.core = RosellaRenderer(
self.model, SOURCE_CHANNELS, create_room=False)
else:
self.dsp_backend = "python"
self.qmf_analysis = QmfAnalysis(SOURCE_CHANNELS, self.kernel_data)
self.hybrid_analysis = HybridAnalysis(SOURCE_CHANNELS, self.kernel_data)
self.core = RosellaRenderer(
self.model, SOURCE_CHANNELS,
room_impulse_slots=room_impulse_slots)
self.hybrid_synthesis = HybridSynthesis(OUTPUT_CHANNELS, self.kernel_data)
self.qmf_synthesis = QmfSynthesis(OUTPUT_CHANNELS, self.kernel_data)
self.timeline = OamdPositionTimeline(15)
self._input_buffer = np.empty(
(self.chunk_samples, SOURCE_CHANNELS), dtype=np.float64)
self._buffer_used = 0
self.input_samples = 0
self.processed_input_samples = 0
self.raw_output_samples = 0
self.output_samples = 0
self.finished = False
self.metadata_block_updates = 0
def _append_input(self, samples: np.ndarray) -> list[np.ndarray]:
outputs = []
source = np.asarray(samples, dtype=np.float64)
position = 0
while position < len(source):
count = min(self.chunk_samples - self._buffer_used,
len(source) - position)
self._input_buffer[self._buffer_used:self._buffer_used + count] = (
source[position:position + count])
self._buffer_used += count
position += count
if self._buffer_used == self.chunk_samples:
outputs.append(self._process_samples(self._input_buffer))
self._buffer_used = 0
return outputs
def render_frame(self, objects16, payload=None, metadata_offset=None,
*, outer_sample_offset=0) -> np.ndarray:
"""Submit one 1536-sample reconstructed frame and its ID11 payload."""
if self.finished:
raise RuntimeError("binaural renderer is already finished")
source = np.asarray(objects16)
if source.shape != (FRAME_SAMPLES, SOURCE_CHANNELS):
raise ValueError(
f"binaural frame must have shape ({FRAME_SAMPLES},{SOURCE_CHANNELS}), "
f"got {source.shape}")
frame_start = self.input_samples
metadata_delay = (self.object_delay_samples if metadata_offset is None
else int(metadata_offset))
if metadata_delay < 0:
raise ValueError("metadata_offset must be non-negative")
if payload is not None:
self.timeline.submit_payload(
payload,
frame_start_sample=frame_start,
outer_sample_offset=int(outer_sample_offset),
object_delay_samples=metadata_delay,
processed_sample=self.processed_input_samples,
)
self.metadata_block_updates += 1
self.input_samples += FRAME_SAMPLES
chunks = self._append_input(source)
if not chunks:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
return np.concatenate(chunks, axis=0) if len(chunks) > 1 else chunks[0]
def _set_block_parameters(self, sample: int):
positions = self.timeline.positions_at(sample)
self.core.set_source(0, (0.0, 1.0, 0.0), special_lfe=True)
for object_index in range(15):
self.core.set_source(
object_index + 1, positions[object_index], self.profile_index)
def _process_samples(self, source: np.ndarray) -> np.ndarray:
values = np.asarray(source, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != SOURCE_CHANNELS:
raise ValueError(f"expected [samples,{SOURCE_CHANNELS}], got {values.shape}")
if len(values) % ROSSELLA_BLOCK_SAMPLES:
raise ValueError("binaural input must be divisible by 512 samples")
blocks = len(values) // ROSSELLA_BLOCK_SAMPLES
block_base = self.processed_input_samples
if self.native_dsp is not None:
stereo = np.empty((len(values), OUTPUT_CHANNELS), dtype=np.float64)
for block in range(blocks):
sample = block_base + block * ROSSELLA_BLOCK_SAMPLES
self._set_block_parameters(sample)
start = block * ROSSELLA_BLOCK_SAMPLES
stop = start + ROSSELLA_BLOCK_SAMPLES
stereo[start:stop] = self.native_dsp.process_block(
values[start:stop], self.core.gains, self.core.room_sends,
self.output_gain)
else:
hops = values.reshape(
blocks, ROSSELLA_BLOCK_SAMPLES // QMF_HOP_SAMPLES,
QMF_HOP_SAMPLES, SOURCE_CHANNELS,
).transpose(0, 1, 3, 2).reshape(
blocks * (ROSSELLA_BLOCK_SAMPLES // QMF_HOP_SAMPLES),
SOURCE_CHANNELS, QMF_HOP_SAMPLES)
hybrid = self.hybrid_analysis.process_chunk(
self.qmf_analysis.process_chunk(hops))
direct = np.empty((blocks * 8, OUTPUT_CHANNELS, 77), dtype=np.complex128)
room_send = np.empty((blocks * 8, 77), dtype=np.complex128)
for block in range(blocks):
sample = block_base + block * ROSSELLA_BLOCK_SAMPLES
self._set_block_parameters(sample)
start = block * 8
stop = start + 8
direct[start:stop], room_send[start:stop] = (
self.core.direct_and_send_static(hybrid[start:stop]))
rendered = direct + self.core.room.process_chunk(room_send)
time_bands = self.qmf_synthesis.process_chunk(
self.hybrid_synthesis.process_chunk(rendered))
stereo = time_bands.transpose(0, 2, 1).reshape(
blocks * ROSSELLA_BLOCK_SAMPLES, OUTPUT_CHANNELS)
stereo *= self.output_gain
skip = max(0, min(
len(stereo), ROSSELLA_LATENCY_SAMPLES - self.raw_output_samples))
self.raw_output_samples += len(stereo)
self.processed_input_samples += len(values)
output = stereo[skip:]
self.output_samples += len(output)
return output
def finish(self) -> np.ndarray:
"""Process pending source samples and preserve the configured room tail."""
if self.finished:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
outputs: list[np.ndarray] = []
if self._buffer_used:
outputs.append(self._process_samples(
self._input_buffer[:self._buffer_used]))
self._buffer_used = 0
flush_samples = math.ceil(
(self.tail_seconds * SAMPLE_RATE
+ ROSSELLA_LATENCY_SAMPLES + ROSSELLA_BLOCK_SAMPLES)
/ ROSSELLA_BLOCK_SAMPLES) * ROSSELLA_BLOCK_SAMPLES
while flush_samples:
count = min(flush_samples, self.chunk_samples)
zero = np.zeros((count, SOURCE_CHANNELS), dtype=np.float64)
outputs.append(self._process_samples(zero))
flush_samples -= count
self.finished = True
nonempty = [value for value in outputs if len(value)]
if not nonempty:
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
return np.concatenate(nonempty, axis=0)
def close(self):
if self.native_dsp is not None:
self.native_dsp.close()
self.finished = True
@property
def backend_info(self) -> dict:
return {
"name": self.dsp_backend,
"precision": "float64/complex128",
"fallback_reason": self.backend_fallback,
"library": (str(self.native_dsp.library_path)
if self.native_dsp is not None else None),
"model": str(self.model_path.resolve()),
"model_coefficients": int(len(self.model.coefficients)),
"model_coefficient_sha256": self.model.coefficient_sha256,
"model_version": self.model.coefficient_version,
"kernel_data": str(self.kernel_data),
"kernel_data_sha256": self.kernel_data_sha256,
"mode": self.mode,
"latency_compensated_samples": ROSSELLA_LATENCY_SAMPLES,
"object_delay_samples": self.object_delay_samples,
"tail_seconds": self.tail_seconds,
"metadata_payloads": self.timeline.payload_count,
"metadata_position_transitions": self.timeline.transition_count,
"input_samples": self.input_samples,
"processed_samples_including_flush": self.processed_input_samples,
"output_samples_before_tail_trim": self.output_samples,
}
+81
View File
@@ -0,0 +1,81 @@
"""Stateful float64/complex128 Rosella hybrid-band renderer."""
from __future__ import annotations
import numpy as np
from rosella_direct import (
PROFILE_MID,
direct_and_room_send,
special_lfe_direct,
)
from rosella_model import RosellaModel
from rosella_room import RosellaRoomFir
class RosellaRenderer:
"""Hold per-source direct parameters and the cross-block room state."""
def __init__(self, model: RosellaModel, source_count: int,
room_impulse_slots: int = 4096, *, create_room: bool = True):
if source_count <= 0:
raise ValueError("source_count must be positive")
self.model = model
self.source_count = int(source_count)
self.room = (RosellaRoomFir(model, impulse_slots=room_impulse_slots)
if create_room else None)
self.positions = np.zeros((self.source_count, 3), dtype=np.float64)
self.positions[:, 1] = 1.0
self.profiles = np.full(self.source_count, PROFILE_MID, dtype=np.int32)
self.special_lfe = np.zeros(self.source_count, dtype=bool)
self.gains = np.empty(
(self.source_count, 2, 77), dtype=np.complex128)
self.room_sends = np.empty(self.source_count, dtype=np.float64)
self._parameter_keys = [None] * self.source_count
for source in range(self.source_count):
self.set_source(source, self.positions[source], PROFILE_MID)
def reset(self):
if self.room is not None:
self.room.reset()
def set_source(self, source: int, position, profile: int = PROFILE_MID,
*, special_lfe: bool = False):
source = int(source)
if not 0 <= source < self.source_count:
raise IndexError(source)
coordinates = np.asarray(position, dtype=np.float64)
if coordinates.shape != (3,) or not np.all(np.isfinite(coordinates)):
raise ValueError(f"source position must be three finite values, got {position!r}")
effective_profile = 0 if special_lfe else int(profile)
key = ((bool(special_lfe), effective_profile)
+ tuple(float(value) for value in coordinates))
if self._parameter_keys[source] == key:
return
self.positions[source] = coordinates
self.profiles[source] = effective_profile
self.special_lfe[source] = bool(special_lfe)
parameters = (special_lfe_direct() if special_lfe else
direct_and_room_send(self.model, coordinates, effective_profile))
self.gains[source] = parameters.gains
self.room_sends[source] = parameters.room_send
self._parameter_keys[source] = key
def direct_and_send_static(self, sources):
"""Mix one static-parameter slot chunk without advancing room state."""
values = np.asarray(sources, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.source_count, 77):
raise ValueError(
f"expected [slots,{self.source_count},77], got {values.shape}")
direct = np.zeros((values.shape[0], 2, 77), dtype=np.complex128)
room_send = np.zeros((values.shape[0], 77), dtype=np.complex128)
for source in range(self.source_count - 1, -1, -1):
direct += values[:, source, None, :] * self.gains[source][None, :, :]
room_send += values[:, source, :] * self.room_sends[source]
return direct, room_send
def process_static_chunk(self, sources) -> np.ndarray:
direct, room_send = self.direct_and_send_static(sources)
if self.room is None:
raise RuntimeError("room renderer is not configured")
direct += self.room.process_chunk(room_send)
return direct
+302
View File
@@ -0,0 +1,302 @@
"""Float64 Rosella direction, distance, HRTF, and room-send calculations."""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
from rosella_model import RosellaModel, direction_basis
PROFILE_NEAR = 1
PROFILE_FAR = 2
PROFILE_MID = 3
BINAURAL_PROFILE_NAMES = {
"near": PROFILE_NEAR,
"far": PROFILE_FAR,
"mid": PROFILE_MID,
}
_SPECIAL_LFE_LOW_16 = np.asarray([
0x402695EA, 0x3FE75979, 0x3F28CAAA, 0xBCE1FB2E,
0xBDD8AF65, 0xBD8F426E, 0x3D996821, 0xBC16B3A0,
0x3B64BAF1, 0xBC81ECFD, 0xBA3D892F, 0x3AF6A9F0,
0xB9DD1C5F, 0x380A193F, 0x38052059, 0x351BCB34,
], dtype=np.uint32).view(np.float32).astype(np.float64)
_CENTRE_EQUAL = 0.9998489618301392
_CENTRE_ALTERNATE = 0.7070000171661377
_FIELD_CACHE: dict[int, tuple[np.ndarray, np.ndarray]] = {}
@dataclass(frozen=True)
class DirectResult:
gains: np.ndarray # complex128 [ear=2, hybrid_band=77]
room_send: np.float64
physical_radius_m: np.float64
normalized_radius: np.float64
clamped_radius: np.float64
delay_samples: np.float64
delayed_ear: int | None
def special_lfe_direct() -> DirectResult:
"""Return the fixed 16-band low-pass used by a special/LFE source."""
mono = np.zeros(77, dtype=np.complex128)
mono[:16] = _SPECIAL_LFE_LOW_16
return DirectResult(
gains=np.repeat(mono[None, :], 2, axis=0),
room_send=np.float64(0.0),
physical_radius_m=np.float64(0.0),
normalized_radius=np.float64(0.0),
clamped_radius=np.float64(0.0),
delay_samples=np.float64(0.0),
delayed_ear=None,
)
def _round_away_from_zero(value: float) -> int:
return math.floor(value + 0.5) if value >= 0.0 else math.ceil(value - 0.5)
def _q15_position(position) -> np.ndarray:
"""Quantize ADM Cartesian coordinates to the Rosella metadata grid.
Quantization is metadata decoding. The returned integer lanes are promoted
to float64 before any geometry is evaluated.
"""
x, y, z = (float(value) for value in position)
encoded = (
min(max((x + 1.0) * 0.5, 0.0), 1.0),
min(max((1.0 - y) * 0.5, 0.0), 1.0),
min(max(z, -1.0), 1.0),
)
return np.asarray([
min(_round_away_from_zero(value * 32768.0), 32767)
for value in encoded
], dtype=np.int32)
def _profile_geometry(model: RosellaModel, position, profile_index: int):
if profile_index not in (PROFILE_NEAR, PROFILE_FAR, PROFILE_MID):
raise ValueError("binaural object profile must be near, mid, or far")
profile = model.profiles[profile_index]
encoded = _q15_position(position)
q_front = 1.0 - 2.0 * float(encoded[1]) / 32768.0
q_x = 2.0 * float(encoded[0]) / 32768.0 - 1.0
q_vertical = float(encoded[2]) / 32768.0
if int(model.header_integer_fields[0]) != 0:
if q_x == 0.0 and q_front == 0.0:
mapped_front = 0.0
mapped_lateral = 0.0
mapped_vertical = q_vertical
else:
horizontal_max = max(abs(q_x), abs(q_front))
horizontal_norm = ((q_x / horizontal_max) ** 2
+ (q_front / horizontal_max) ** 2)
if q_vertical == 0.0:
vertical_norm = 1.0
else:
smaller = min(abs(q_vertical), horizontal_max)
larger = max(abs(q_vertical), horizontal_max)
vertical_norm = 1.0 + (smaller / larger) ** 2
horizontal_factor = 1.0 / math.sqrt(horizontal_norm * vertical_norm)
vertical_factor = 1.0 / math.sqrt(vertical_norm)
mapped_front = q_front * horizontal_factor
mapped_lateral = -q_x * horizontal_factor
mapped_vertical = q_vertical * vertical_factor
else:
mapped_front = q_front
mapped_lateral = -q_x
mapped_vertical = q_vertical
scales = np.asarray(profile.axis_scales_internal, dtype=np.float64)
scaled = np.asarray([
mapped_front * scales[2],
mapped_lateral * scales[0],
mapped_vertical * scales[1],
], dtype=np.float64)
bounds = np.asarray(profile.bounds, dtype=np.float64)
ray = 1.0
for axis in range(3):
value = scaled[axis]
lower, upper = bounds[axis * 2:axis * 2 + 2]
if value < lower:
ray = min(ray, lower / value)
elif value > upper:
ray = min(ray, upper / value)
if ray < 1.0:
scaled *= ray
radius = float(np.linalg.norm(scaled))
clamped = max(radius, float(profile.minimum_normalized_radius))
alpha = radius / clamped
direction = (scaled / radius if radius > 1.0e-30
else np.asarray([1.0, 0.0, 0.0], dtype=np.float64))
return profile, direction, radius, clamped, alpha
def _logical_field(padded: np.ndarray) -> np.ndarray:
result = np.empty((77, 36, 2), dtype=np.float64)
source = np.asarray(padded, dtype=np.float64)
for band in range(77):
block, lane = divmod(band, 4)
for term in range(36):
for component in range(2):
result[band, term, component] = source[
lane + 4 * (term * 2 + component + 72 * block)]
return result
def _model_fields(model: RosellaModel) -> tuple[np.ndarray, np.ndarray]:
key = id(model)
fields = _FIELD_CACHE.get(key)
if fields is None:
fields = (_logical_field(model.field_left_padded),
_logical_field(model.field_right_padded))
_FIELD_CACHE[key] = fields
return fields
def _ear_geometry(model: RosellaModel, profile, direction, clamped: float,
offset: float, correction: float):
x, y, z = (float(value) for value in direction)
inverse_distance = float(profile.inverse_distance_per_m)
ear = float(offset) * inverse_distance / clamped
y_minus = y - ear
y_plus = y + ear
common = x * x + z * z
length_minus = math.sqrt(y_minus * y_minus + common)
length_plus = math.sqrt(y_plus * y_plus + common)
basis_minus = direction_basis(
x / length_minus, y_minus / length_minus, z / length_minus,
dtype=np.float64)
basis_plus = direction_basis(
x / length_plus, y_plus / length_plus, z / length_plus,
dtype=np.float64)
path_minus = length_minus * clamped
path_plus = length_plus * clamped
if correction != 0.0:
multiplier = 2.0 * float(correction) * inverse_distance
path_minus += max(float(np.dot(
np.asarray(model.vector_left, dtype=np.float64), basis_minus)), 0.0) * multiplier
path_plus += max(float(np.dot(
np.asarray(model.vector_right, dtype=np.float64), basis_plus)), 0.0) * multiplier
return basis_minus, basis_plus, path_minus, path_plus
def _phase_groups(model: RosellaModel, delay_samples: float) -> np.ndarray:
result = np.ones(77, dtype=np.complex128)
current = 1.0 + 0.0j
step = 1.0 + 0.0j
value_index = 0
for band, flag in enumerate(model.hybrid_flags):
if flag != 2:
if flag == 1:
angle = float(model.hybrid_values[value_index]) * delay_samples
value_index += 1
step = complex(math.cos(angle), math.sin(angle))
current *= step
result[band] = current
return result
def direct_and_room_send(model: RosellaModel, position,
profile_index: int) -> DirectResult:
"""Evaluate one ordinary source using float64/complex128 throughout."""
profile, direction, radius, clamped, alpha = _profile_geometry(
model, position, profile_index)
_, _, path_minus, path_plus = _ear_geometry(
model, profile, direction, clamped,
float(model.model_scalars[1]), float(model.model_scalars[2]))
delay = (abs(path_plus - path_minus)
* float(profile.distance_scale_m)
* (float(model.sample_rate) / 343.3) * alpha)
delayed_ear = 0 if path_minus > path_plus else (
1 if path_plus > path_minus else None)
_, _, weight_minus_path, weight_plus_path = _ear_geometry(
model, profile, direction, clamped,
float(model.model_scalars[3]), float(model.model_scalars[4]))
weight_norm = math.sqrt(
weight_minus_path * weight_minus_path
+ weight_plus_path * weight_plus_path)
weight_left = weight_plus_path / weight_norm
weight_right = weight_minus_path / weight_norm
final_offset = float(model.model_scalars[0])
if final_offset == 0.0:
basis_minus = direction_basis(*direction, dtype=np.float64)
basis_plus = basis_minus.copy()
else:
x, y, z = (float(value) for value in direction)
ear = final_offset * float(profile.inverse_distance_per_m) / clamped
y_minus = y - ear
y_plus = y + ear
common_length = x * x + z * z
length_minus = math.sqrt(y_minus * y_minus + common_length)
length_plus = math.sqrt(y_plus * y_plus + common_length)
basis_minus = direction_basis(
x / length_minus, y_minus / length_minus, z / length_minus,
dtype=np.float64)
basis_plus = direction_basis(
x / length_plus, y_plus / length_plus, z / length_plus,
dtype=np.float64)
field_left, field_right = _model_fields(model)
left_components = np.einsum(
"bjc,j->bc", field_left, basis_minus,
dtype=np.float64, optimize=False)
right_components = np.einsum(
"bjc,j->bc", field_right, basis_plus,
dtype=np.float64, optimize=False)
left = left_components[:, 0] + 1j * left_components[:, 1]
right = right_components[:, 0] + 1j * right_components[:, 1]
if delayed_ear is not None:
phase = _phase_groups(model, delay)
if delayed_ear == 0:
left *= phase
else:
right *= phase
effective_radius = (radius * float(model.header_float_scalars[0])
* float(profile.distance_scale_m))
if profile_index in (PROFILE_FAR, PROFILE_MID):
common = 1.0 / math.sqrt(
1.0 + float(model.header_float_scalars[1])
* effective_radius * effective_radius)
room_send = effective_radius * common
else:
common = 1.0
room_send = 0.0
left_term0 = field_left[:, 0, 0] + 1j * field_left[:, 0, 1]
right_term0 = field_right[:, 0, 0] + 1j * field_right[:, 0, 1]
weights_are_default_equal = (
float(model.model_scalars[3]) == 0.0
and float(model.model_scalars[4]) == 0.0)
if weights_are_default_equal:
centre_left = weight_left * (1.0 - alpha) * _CENTRE_EQUAL
centre_right = centre_left
right_direction_weight = weight_left
else:
centre_left = (1.0 - alpha) * _CENTRE_ALTERNATE
centre_right = centre_left
right_direction_weight = weight_right
gains = np.empty((2, 77), dtype=np.complex128)
gains[0] = common * (
left * (weight_left * alpha) + left_term0 * centre_left)
gains[1] = common * (
right * (right_direction_weight * alpha) + right_term0 * centre_right)
return DirectResult(
gains=gains,
room_send=np.float64(room_send),
physical_radius_m=np.float64(float(profile.distance_scale_m) * radius),
normalized_radius=np.float64(radius),
clamped_radius=np.float64(clamped),
delay_samples=np.float64(delay),
delayed_ear=delayed_ear,
)
+190
View File
@@ -0,0 +1,190 @@
"""Float64/complex128 Rosella QMF and hybrid filterbanks."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import numpy as np
PROJECT_DIR = Path(__file__).resolve().parent.parent
DEFAULT_KERNEL_DATA = PROJECT_DIR / "data" / "rosella_kernels.npz"
@lru_cache(maxsize=4)
def _load_tables(path_string: str) -> dict[str, np.ndarray]:
path = Path(path_string)
if not path.is_file():
raise FileNotFoundError(f"Rosella kernel data not found: {path}")
with np.load(path, allow_pickle=False) as archive:
version = archive["format_version"]
if version.shape != (1,) or int(version[0]) != 1:
raise ValueError(f"unsupported Rosella kernel data version in {path}")
return {name: archive[name].copy() for name in archive.files}
def load_kernel_tables(path: str | Path = DEFAULT_KERNEL_DATA) -> dict[str, np.ndarray]:
"""Load and cache the compact, production Rosella kernel tables."""
return _load_tables(str(Path(path).expanduser().resolve()))
class QmfAnalysis:
"""Batchable 64-band analysis with float64 state and complex128 FFTs."""
def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_kernel_tables(kernel_data)
self.coefficients = np.asarray(
tables["qmf_analysis_coefficients"], dtype=np.float64)
if self.coefficients.shape != (64, 10):
raise ValueError("invalid qmf_analysis_coefficients shape")
self.channels = int(channels)
self.history = np.zeros((9, self.channels, 64), dtype=np.float64)
phase = np.arange(64, dtype=np.float64)
self.premod = np.exp(-1j * np.pi * phase / 128.0).astype(np.complex128)
self.post = np.exp(
-1j * 3.0 * (np.arange(64, dtype=np.float64) + 0.5) * np.pi / 128.0
).astype(np.complex128)
self.even_post = (
1j * ((-1.0) ** np.arange(64, dtype=np.float64))
).astype(np.complex128)
def reset(self):
self.history.fill(0.0)
def process_chunk(self, hops) -> np.ndarray:
values = np.asarray(hops, dtype=np.float64)
if values.ndim != 3 or values.shape[1:] != (self.channels, 64):
raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}")
count = values.shape[0]
joined = np.concatenate((self.history, values), axis=0)
even = np.zeros_like(values)
odd = np.zeros_like(values)
for lag in range(10):
source = joined[9 - lag:9 - lag + count]
target = even if lag % 2 == 0 else odd
target += source * self.coefficients[:, lag][None, None, :]
self.history[:] = joined[-9:]
def transform(block):
prepared = block.astype(np.complex128, copy=False) * self.premod
transformed = np.fft.fft(prepared, n=128, axis=-1)[..., :64]
return transformed * self.post
return np.asarray(transform(odd) + transform(even) * self.even_post,
dtype=np.complex128)
class HybridAnalysis:
"""Sparse 64-QMF to 77-hybrid analysis in float64/complex128."""
def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_kernel_tables(kernel_data)
self.low_kernel = np.asarray(
tables["hybrid_analysis_low_kernel"], dtype=np.float64)
if self.low_kernel.shape != (3, 2, 13, 16, 2):
raise ValueError("invalid hybrid_analysis_low_kernel shape")
self.channels = int(channels)
self.history = np.zeros((12, self.channels, 3, 2), dtype=np.float64)
self.high_history = np.zeros(
(6, self.channels, 61), dtype=np.complex128)
def reset(self):
self.history.fill(0.0)
self.high_history.fill(0.0)
def process_chunk(self, qmf) -> np.ndarray:
values = np.asarray(qmf, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, 64):
raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}")
count = values.shape[0]
low = np.stack((values[:, :, :3].real, values[:, :, :3].imag), axis=-1)
joined = np.concatenate((self.history, low), axis=0)
output = np.zeros((count, self.channels, 77, 2), dtype=np.float64)
for lag in range(13):
source = joined[12 - lag:12 - lag + count]
output[:, :, :16] += np.einsum(
"tcpi,pibo->tcbo", source, self.low_kernel[:, :, lag],
dtype=np.float64, optimize=False)
self.history[:] = joined[-12:]
high_joined = np.concatenate((self.high_history, values[:, :, 3:]), axis=0)
high = high_joined[:count]
output[:, :, 16:, 0] = high.real
output[:, :, 16:, 1] = high.imag
self.high_history[:] = high_joined[-6:]
return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128)
class HybridSynthesis:
"""Instantaneous sparse 77-hybrid to 64-QMF synthesis map."""
def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_kernel_tables(kernel_data)
indices = np.asarray(tables["hybrid_synthesis_indices"], dtype=np.int64)
values = np.asarray(tables["hybrid_synthesis_values"], dtype=np.float64)
if indices.ndim != 2 or indices.shape[1] != 4 or len(indices) != len(values):
raise ValueError("invalid hybrid synthesis sparse table")
self.mapping = [
(int(index[0]), int(index[1]), int(index[2]), int(index[3]), float(value))
for index, value in zip(indices, values)
]
self.channels = int(channels)
def reset(self):
return None
def process_chunk(self, hybrid) -> np.ndarray:
values = np.asarray(hybrid, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, 77):
raise ValueError(f"expected [slots,{self.channels},77], got {values.shape}")
source = np.stack((values.real, values.imag), axis=-1)
output = np.zeros((values.shape[0], self.channels, 64, 2), dtype=np.float64)
for input_band, input_component, output_band, output_component, gain in self.mapping:
output[:, :, output_band, output_component] += (
source[:, :, input_band, input_component] * gain)
return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128)
class QmfSynthesis:
"""Rank-4 64-band synthesis with float64 state and accumulation."""
def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA):
if channels <= 0:
raise ValueError("channels must be positive")
tables = load_kernel_tables(kernel_data)
self.basis = np.asarray(tables["qmf_synthesis_basis"], dtype=np.float64)
self.taps = np.asarray(tables["qmf_synthesis_taps"], dtype=np.float64)
if self.basis.shape != (64, 4, 128) or self.taps.shape != (64, 10, 4):
raise ValueError("invalid QMF synthesis factorization")
self.channels = int(channels)
self.rank = 4
self.history = np.zeros(
(9, self.channels, 64, self.rank), dtype=np.float64)
def reset(self):
self.history.fill(0.0)
def process_chunk(self, qmf) -> np.ndarray:
values = np.asarray(qmf, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.channels, 64):
raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}")
count = values.shape[0]
flat = np.stack((values.real, values.imag), axis=-1).reshape(
count * self.channels, 128)
modulation = self.basis.reshape(64 * self.rank, 128)
features = (flat @ modulation.T).reshape(
count, self.channels, 64, self.rank)
joined = np.concatenate((self.history, features), axis=0)
output = np.zeros((count, self.channels, 64), dtype=np.float64)
for lag in range(10):
output += np.sum(
joined[9 - lag:9 - lag + count]
* self.taps[:, lag, :][None, None, :, :],
axis=-1, dtype=np.float64)
self.history[:] = joined[-9:]
return output
+517
View File
@@ -0,0 +1,517 @@
"""Parser for ``.personalized_headphone`` and raw ``rp`` models."""
from __future__ import annotations
import hashlib
import json
import math
from numbers import Real
import struct
from dataclasses import dataclass
from pathlib import Path
import numpy as np
Q15 = np.float32(1.0 / 32768.0)
def _f32(value) -> np.float32:
return np.float32(value)
def _q15(value: int) -> np.float32:
return _f32(_f32(value) * Q15)
def _q15_exp(value: int, exponent: int) -> np.float32:
return _f32(_q15(value) * _f32(np.ldexp(1.0, exponent)))
@dataclass(frozen=True)
class DistanceProfile:
bounds: np.ndarray
distance_scale_m: np.float32
inverse_distance_per_m: np.float32
axis_scales_internal: np.ndarray
minimum_normalized_radius: np.float32
@property
def floats(self) -> np.ndarray:
return np.concatenate((
self.bounds,
np.asarray([self.distance_scale_m,
self.inverse_distance_per_m], dtype=np.float32),
self.axis_scales_internal,
np.asarray([self.minimum_normalized_radius], dtype=np.float32),
))
@dataclass(frozen=True)
class RosellaModel:
source_path: str
coefficients: np.ndarray
coefficient_sha256: str
coefficient_version: str | None
room_model: str | None
table_a_dimension: int
table_a_option: int
table_a_extra: int
table_a_header_field: int
table_a_header_25: int
table_a_control: int
table_a_option_ids: np.ndarray
table_a_option_values: np.ndarray
table_a_scalar: np.float32
table_a_filter_16x64_padded: np.ndarray
table_a_four_integers: np.ndarray
table_a_integer: int
table_a_filter_8x64_padded: np.ndarray
table_a_vector16: np.ndarray
table_a_filter_4x64_padded: np.ndarray
table_a_extra_indices: np.ndarray
table_a_extra_fields_padded: np.ndarray
table_a_extra_vectors: np.ndarray
sample_rate: int
matrix_exponent: int
field_exponent: int
matrix_left: np.ndarray
matrix_right: np.ndarray
vector_left: np.ndarray
vector_right: np.ndarray
field_left_padded: np.ndarray
field_right_padded: np.ndarray
field_left_odd_serialized_zero: bool
hybrid_flags: np.ndarray
hybrid_values: np.ndarray
model_scalars: np.ndarray
header_float_scalars: np.ndarray
header_integer_fields: np.ndarray
profiles: tuple[DistanceProfile, ...]
profile_tail: np.ndarray
post_fields: np.ndarray
table_a_main_serialized: np.ndarray
def _lane(data: bytes, index: int) -> int:
if (index + 1) * 4 > len(data):
raise ValueError(f"Rosella rp truncated before int32 lane {index}")
return struct.unpack_from("<I", data, index * 4)[0]
def inspect_rp(data: bytes) -> dict:
"""Return the active-lane layout and checksum status for one raw rp image."""
if len(data) < 20 or len(data) % 4:
raise ValueError("Rosella rp must contain whole little-endian int32 lanes")
if _lane(data, 0) != 0x7072:
raise ValueError(f"bad Rosella rp magic: 0x{_lane(data, 0):08X}")
low16 = lambda value: value & 0xFFFF
checksum = low16(_lane(data, 1))
table_a_present = low16(_lane(data, 2))
table_b_present = low16(_lane(data, 3))
table_c_present = low16(_lane(data, 4))
index = 5
if table_a_present:
table_a_dimension = low16(_lane(data, index))
table_a_option = low16(_lane(data, index + 1))
table_a_extra = low16(_lane(data, index + 2))
index += 5
else:
table_a_dimension, table_a_option, table_a_extra = 77, 0, 0
if table_b_present:
if not table_a_present:
raise ValueError("Rosella rp table B cannot be present without table A")
table_b_dimension = low16(_lane(data, index))
table_b_extra = low16(_lane(data, index + 1))
table_b_groups = low16(_lane(data, index + 2))
index += 3
else:
table_b_dimension = table_b_extra = table_b_groups = 0
if table_c_present:
table_c_dimension = low16(_lane(data, index))
index += 1
else:
table_c_dimension = 0
payload_words = (
(index - 2)
+ table_b_present * (
table_b_dimension + 380 * table_b_groups + table_b_extra + 79)
+ table_a_present * (
171 * table_a_extra + 79 + 2 * (table_a_option + 14 * table_a_dimension))
+ 11
+ table_c_present * (314 * table_c_dimension + 1)
)
total_lanes = 2 + payload_words
if len(data) < total_lanes * 4:
raise ValueError(
f"Rosella rp truncated: need {total_lanes * 4} bytes, have {len(data)}")
computed = 0xA569
for lane_index in range(2, total_lanes):
computed ^= low16(_lane(data, lane_index))
computed &= 0xFFFF
return {
"stored_checksum": checksum,
"computed_checksum": computed,
"checksum_valid": computed == checksum,
"table_a_present": table_a_present,
"table_b_present": table_b_present,
"table_c_present": table_c_present,
"table_a_dimension": table_a_dimension,
"table_a_option": table_a_option,
"table_a_extra": table_a_extra,
"table_b_dimension": table_b_dimension,
"table_b_extra": table_b_extra,
"table_b_groups": table_b_groups,
"table_c_dimension": table_c_dimension,
"active_int32_lanes": total_lanes,
}
def _json_int32(values) -> np.ndarray:
if not isinstance(values, list):
raise ValueError("rosella_coefficients must be a JSON array")
result = np.empty(len(values), dtype=np.int32)
for index, value in enumerate(values):
if isinstance(value, bool) or not isinstance(value, Real):
raise ValueError(f"rosella_coefficients[{index}] is not a number")
numeric = float(value)
if not math.isfinite(numeric) or numeric != math.trunc(numeric):
raise ValueError(
f"rosella_coefficients[{index}] is not an exact integer: {value!r}")
integer = int(value)
if integer < -(1 << 31) or integer > (1 << 31) - 1:
raise ValueError(
f"rosella_coefficients[{index}] is outside signed int32: {integer}")
result[index] = integer
return result
def _load_coefficients(path: Path) -> tuple[np.ndarray, str | None, str | None]:
if not path.is_file():
raise FileNotFoundError(path)
source = path.read_bytes()
stripped = source.lstrip()
if stripped.startswith(b"{"):
try:
document = json.loads(source.decode("utf-8"))
virtualizer = document["personalized_hrtf"]["virtualizer_parameters"]
coefficients = _json_int32(virtualizer["rosella_coefficients"])
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
raise ValueError(f"invalid personalized_headphone JSON: {exc}") from exc
version = virtualizer.get("rosella_coefficients_version")
room = virtualizer.get("room_model")
else:
if len(source) % 4:
raise ValueError("raw rp payload must contain complete int32 lanes")
coefficients = np.frombuffer(source, dtype="<i4").copy()
version = room = None
return coefficients, version, room
def _unpack_field(serialized: np.ndarray, directions: int,
exponent: int) -> np.ndarray:
expected = 154 * directions
if serialized.size != expected:
raise ValueError(f"expected {expected} field lanes, got {serialized.size}")
padded = np.zeros(160 * directions, dtype=np.float32)
stride8 = 8 * directions
stride2 = 2 * directions
for source_index, value in enumerate(serialized):
group4 = (source_index % stride8) // stride2
destination = ((group4 & 3) + 4 * (
source_index % stride2 +
2 * directions * (source_index // stride8 + (group4 >> 2))))
padded[destination] = _q15_exp(int(value), exponent)
return padded
def _unpack_table_a_grid(serialized: np.ndarray, dimension: int,
serialized_rows: int, padded_rows: int,
lane_group: int) -> np.ndarray:
if serialized.size != serialized_rows * dimension:
raise ValueError("unexpected table-A grid size")
padded = np.zeros(padded_rows * dimension, dtype=np.float32)
group_width = lane_group * 4
for source_index, value in enumerate(serialized):
remainder = source_index % group_width
destination = ((remainder // lane_group) + 4 * (
remainder % lane_group +
group_width // 4 * (source_index // group_width)))
padded[destination] = _q15(int(value))
return padded
def _unpack_table_a_extra(serialized: np.ndarray) -> np.ndarray:
if serialized.size != 154:
raise ValueError("table-A extra field must contain 154 serialized values")
padded = np.zeros(160, dtype=np.float32)
for source_index, value in enumerate(serialized):
remainder = source_index & 7
destination = ((remainder >> 1) + 4 * (
(source_index & 1) + 2 * (source_index >> 3)))
padded[destination] = _q15(int(value))
return padded
def _parse_profile(values: np.ndarray, position: int) -> tuple[DistanceProfile, int]:
bounds = np.asarray([_q15(int(value)) for value in values[position:position + 6]],
dtype=np.float32)
position += 6
distance = _q15_exp(int(values[position]), int(values[position + 1]))
position += 2
remaining = np.asarray(
[_q15(int(value)) for value in values[position:position + 5]],
dtype=np.float32)
position += 5
return DistanceProfile(
bounds=bounds,
distance_scale_m=distance,
inverse_distance_per_m=remaining[0],
axis_scales_internal=remaining[1:4],
minimum_normalized_radius=remaining[4],
), position
def load_personalized_headphone(path: str | Path) -> RosellaModel:
path = Path(path).resolve()
coefficients, version, room = _load_coefficients(path)
raw = coefficients.astype("<i4", copy=False).tobytes()
header = inspect_rp(raw)
if not header["checksum_valid"] or header["active_int32_lanes"] != coefficients.size:
raise ValueError("invalid or non-active Rosella rp coefficient sequence")
if not header["table_a_present"] or not header["table_b_present"] or header["table_c_present"]:
raise NotImplementedError("current local renderer requires table A+B and no table C")
if header["table_a_dimension"] != 64 or header["table_a_option"] != 3:
raise NotImplementedError("current local renderer requires the observed 64-channel HQMF layout")
if header["table_b_dimension"] != 20 or header["table_b_groups"] != 36:
raise NotImplementedError("current local renderer requires 20 hybrid groups and 36 direction terms")
values = coefficients
extra = header["table_a_extra"]
table_a_main_start = 13
position = table_a_main_start
table_a_control = int(values[position]) & 0xFFFF
field_exponent = int(values[position])
position += 1
option_count = header["table_a_option"]
option_ids = (values[position:position + option_count].astype(np.int64) &
0xFFFF).astype(np.int32)
position += option_count
option_values = np.asarray(
[_q15(int(value)) for value in values[position:position + option_count]],
dtype=np.float32)
position += option_count
table_a_scalar = _q15(int(values[position]))
position += 1
dimension = header["table_a_dimension"]
table_a_filter_16x64 = _unpack_table_a_grid(
values[position:position + 16 * dimension], dimension, 16, 20, 16)
position += 16 * dimension
table_a_four_integers = (values[position:position + 4].astype(np.int64) &
0xFFFF).astype(np.int32)
position += 4
table_a_integer = int(values[position]) & 0xFFFF
position += 1
table_a_filter_8x64 = _unpack_table_a_grid(
values[position:position + 8 * dimension], dimension, 8, 10, 8)
position += 8 * dimension
table_a_vector16 = np.asarray(
[_q15(int(value)) for value in values[position:position + 16]],
dtype=np.float32)
position += 16
table_a_filter_4x64 = _unpack_table_a_grid(
values[position:position + 4 * dimension], dimension, 4, 5, 4)
position += 4 * dimension
extra_indices = (values[position:position + extra].astype(np.int64) &
0xFFFF).astype(np.int32)
position += extra
extra_fields = np.empty((extra, 160), dtype=np.float32)
for index in range(extra):
extra_fields[index] = _unpack_table_a_extra(values[position:position + 154])
position += 154
extra_vectors = np.empty((extra, 16), dtype=np.float32)
for index in range(extra):
extra_vectors[index] = np.asarray(
[_q15(int(value)) for value in values[position:position + 16]],
dtype=np.float32)
position += 16
table_b_start = position
expected_table_b_start = table_a_main_start + 1821 + 171 * extra
if table_b_start != expected_table_b_start:
raise AssertionError(
f"table-A parser ended at {table_b_start}, expected {expected_table_b_start}")
table_a_main = values[table_a_main_start:table_b_start].copy()
sample_rate = 2 * (int(values[position]) & 0xFFFF)
position += 1
matrix_exponent = int(values[position])
position += 1
matrix_count = 36 * 36
scale_matrix = lambda block: np.asarray(
[_q15_exp(int(value), matrix_exponent) for value in block],
dtype=np.float32).reshape(36, 36)
matrix_left = scale_matrix(values[position:position + matrix_count])
position += matrix_count
matrix_right = scale_matrix(values[position:position + matrix_count])
position += matrix_count
vector_left = np.asarray(
[_q15_exp(int(value), matrix_exponent)
for value in values[position:position + 36]], dtype=np.float32)
position += 36
vector_right = np.asarray(
[_q15_exp(int(value), matrix_exponent)
for value in values[position:position + 36]], dtype=np.float32)
position += 36
serialized_count = 154 * 36
field_left_serialized = values[position:position + serialized_count]
field_left = _unpack_field(field_left_serialized, 36, field_exponent)
field_left_odd_zero = not np.any(
np.abs(np.asarray([_q15_exp(int(value), field_exponent)
for value in field_left_serialized[1::2]],
dtype=np.float32)) > np.float32(1e-6))
position += serialized_count
field_right = _unpack_field(
values[position:position + serialized_count], 36, field_exponent)
position += serialized_count
hybrid_flags = (values[position:position + 20].astype(np.int64) & 0xFFFF).astype(np.int32)
position += 20
active_hybrid_values = int(np.count_nonzero(hybrid_flags == 1))
if active_hybrid_values != header["table_b_extra"]:
raise ValueError(
f"hybrid value count {active_hybrid_values} != header {header['table_b_extra']}")
hybrid_values = np.asarray(
[_q15(int(value)) for value in values[position:position + active_hybrid_values]],
dtype=np.float32)
position += active_hybrid_values
model_scalars = np.asarray(
[_q15(int(value)) for value in values[position:position + 5]],
dtype=np.float32)
position += 5
expected_table_a_tail = table_b_start + (
header["table_b_dimension"] +
380 * header["table_b_groups"] +
header["table_b_extra"] + 79)
if position != expected_table_a_tail:
raise AssertionError(f"table-B parser ended at {position}, expected {expected_table_a_tail}")
header_float_scalars = np.asarray([
_q15(int(values[position])),
_f32(_q15(int(values[position + 1])) * _f32(16.0)),
], dtype=np.float32)
header_integer_fields = np.asarray([
int(values[position + 2]),
int(values[position + 3]) & 0xFFFF,
], dtype=np.int32)
position += 4
profiles = []
for _ in range(4):
profile, position = _parse_profile(values, position)
profiles.append(profile)
profile_tail = np.asarray(
[_q15(int(value)) for value in values[position:position + 8]],
dtype=np.float32)
position += 8
post_fields = values[position:position + 3].astype(np.int32, copy=True)
position += 3
if position != values.size:
raise AssertionError(f"unparsed coefficient lanes: {values.size - position}")
return RosellaModel(
source_path=str(path),
coefficients=coefficients,
coefficient_sha256=hashlib.sha256(raw).hexdigest(),
coefficient_version=version,
room_model=room,
table_a_dimension=header["table_a_dimension"],
table_a_option=header["table_a_option"],
table_a_extra=extra,
table_a_header_field=int(values[8]) & 0xFFFF,
table_a_header_25=int(values[9]) & 0xFFFF,
table_a_control=table_a_control,
table_a_option_ids=option_ids,
table_a_option_values=option_values,
table_a_scalar=table_a_scalar,
table_a_filter_16x64_padded=table_a_filter_16x64,
table_a_four_integers=table_a_four_integers,
table_a_integer=table_a_integer,
table_a_filter_8x64_padded=table_a_filter_8x64,
table_a_vector16=table_a_vector16,
table_a_filter_4x64_padded=table_a_filter_4x64,
table_a_extra_indices=extra_indices,
table_a_extra_fields_padded=extra_fields,
table_a_extra_vectors=extra_vectors,
sample_rate=sample_rate,
matrix_exponent=matrix_exponent,
field_exponent=field_exponent,
matrix_left=matrix_left,
matrix_right=matrix_right,
vector_left=vector_left,
vector_right=vector_right,
field_left_padded=field_left,
field_right_padded=field_right,
field_left_odd_serialized_zero=field_left_odd_zero,
hybrid_flags=hybrid_flags,
hybrid_values=hybrid_values,
model_scalars=model_scalars,
header_float_scalars=header_float_scalars,
header_integer_fields=header_integer_fields,
profiles=tuple(profiles),
profile_tail=profile_tail,
post_fields=post_fields,
table_a_main_serialized=table_a_main,
)
def direction_basis(x: float, y: float, z: float,
dtype=np.float64) -> np.ndarray:
"""Return the observed 36-term Rosella direction basis."""
f = dtype
x, y, z = f(x), f(y), f(z)
out = np.empty(36, dtype=dtype)
yz = f(y * z)
x2 = f(x * x)
y2 = f(y * y)
x2m02 = f(x2 - f(0.2))
xy = f(x * y)
out[0:4] = (f(1.0), x, y, z)
out[4] = f(x2 - f(1.0 / 3.0))
out[5] = xy
out[6] = f(x * z)
out[7] = f(y2 - f(1.0 / 3.0))
out[8] = yz
out[9] = f(f(x2 - f(0.6)) * x)
out[10] = f(x2m02 * y)
out[11] = f(x2m02 * z)
out[12] = f(f(y2 - f(0.2)) * x)
out[13] = f(yz * x)
out[14] = f(f(y2 - f(0.6)) * y)
out[15] = f(f(y2 - f(0.2)) * z)
out[16] = f(f(x2 * x2) - f(0.2))
out[17] = f(xy * x2)
out[18] = f(f(x * z) * x2)
out[19] = f(f(y2 * x2) - f(1.0 / 15.0))
out[20] = f(yz * x2)
out[21] = f(x * y2 * y)
out[22] = f(x * y2 * z)
out[23] = f(f(y2 * y2) - f(0.2))
x4 = f(x2 * x2)
x2y2 = f(y2 * x2)
y4 = f(y2 * y2)
out[24] = f(yz * y2)
out[25] = f(f(x4 - f(3.0 / 7.0)) * x)
out[26] = f(f(x4 - f(3.0 / 35.0)) * y)
out[27] = f(f(x4 - f(3.0 / 35.0)) * z)
out[28] = f(f(x2y2 - f(3.0 / 35.0)) * x)
out[29] = f(f(x2 * z) * xy)
out[30] = f(f(x2y2 - f(3.0 / 35.0)) * y)
out[31] = f(f(x2y2 - f(1.0 / 35.0)) * z)
out[32] = f(f(y4 - f(3.0 / 35.0)) * x)
out[33] = f(f(y2 * z) * xy)
out[34] = f(f(y4 - f(3.0 / 7.0)) * y)
out[35] = f(f(y4 - f(3.0 / 35.0)) * z)
return out
+198
View File
@@ -0,0 +1,198 @@
"""Float64 Rosella table-A room model and overlap-add realization."""
from __future__ import annotations
import numpy as np
from rosella_model import RosellaModel
class _RosellaRoomState:
"""Recursive table-A state used to generate the stable FIR realization."""
def __init__(self, model: RosellaModel):
self.model = model
self.bands = min(64, model.table_a_dimension)
self.delays = model.table_a_four_integers.astype(np.int32)
self.capacity = int(np.max(self.delays))
self.matrix = np.asarray(model.table_a_vector16, dtype=np.float64).reshape(
4, 4, order="F")
f8 = np.asarray(model.table_a_filter_8x64_padded, dtype=np.float64).reshape(
20, 4, 2, 4)
f4 = np.asarray(model.table_a_filter_4x64_padded, dtype=np.float64).reshape(
20, 4, 4)
f16 = np.asarray(model.table_a_filter_16x64_padded, dtype=np.float64).reshape(
20, 4, 4, 4)
self.feedback_real = np.empty((self.bands, 4), dtype=np.float64)
self.feedback_imag = np.empty_like(self.feedback_real)
self.output_tap = np.empty_like(self.feedback_real)
self.left_real = np.empty_like(self.feedback_real)
self.left_imag = np.empty_like(self.feedback_real)
self.right_real = np.empty_like(self.feedback_real)
self.right_imag = np.empty_like(self.feedback_real)
for band in range(self.bands):
group, lane = divmod(band, 4)
self.feedback_real[band] = f8[group, :, 0, lane]
self.feedback_imag[band] = f8[group, :, 1, lane]
self.output_tap[band] = f4[group, :, lane]
self.left_real[band] = f16[group, :, 0, lane]
self.left_imag[band] = f16[group, :, 1, lane]
self.right_real[band] = f16[group, :, 2, lane]
self.right_imag[band] = f16[group, :, 3, lane]
self.allpass_gain = np.asarray(model.table_a_option_values, dtype=np.float64)
self.allpass_delay = model.table_a_option_ids.astype(np.int32)
self.allpass_real = [
np.zeros((int(delay), self.bands), dtype=np.float64)
for delay in self.allpass_delay
]
self.allpass_imag = [np.zeros_like(value) for value in self.allpass_real]
self.allpass_position = np.zeros(len(self.allpass_real), dtype=np.int32)
self.memory_real = np.zeros(
(self.capacity, self.bands, 4), dtype=np.float64)
self.memory_imag = np.zeros_like(self.memory_real)
self.position = 0
self.extra_fields = np.asarray(
model.table_a_extra_fields_padded, dtype=np.float64).reshape(-1, 20, 2, 4)
self.extra_matrices = [
np.asarray(value, dtype=np.float64).reshape(4, 4, order="F")
for value in model.table_a_extra_vectors
]
def reset(self):
for value in self.allpass_real + self.allpass_imag:
value.fill(0.0)
self.allpass_position.fill(0)
self.memory_real.fill(0.0)
self.memory_imag.fill(0.0)
self.position = 0
def process_slot(self, room_send) -> np.ndarray:
values = np.asarray(room_send, dtype=np.complex128)
input_real = values[:self.bands].real * 0.70710677
input_imag = values[:self.bands].imag * 0.70710677
if float(self.model.table_a_scalar) >= 0.5:
raise NotImplementedError("alternate Rosella table-A room mode")
for index, gain in enumerate(self.allpass_gain):
position = int(self.allpass_position[index])
previous_real = self.allpass_real[index][position].copy()
previous_imag = self.allpass_imag[index][position].copy()
residual_real = input_real - previous_real * gain
residual_imag = input_imag - previous_imag * gain
input_real = residual_real * gain + previous_real
input_imag = residual_imag * gain + previous_imag
self.allpass_real[index][position] = residual_real
self.allpass_imag[index][position] = residual_imag
self.allpass_position[index] = (
position + 1) % len(self.allpass_real[index])
branch_real = np.repeat(input_real[:, None], 4, axis=1)
branch_imag = np.repeat(input_imag[:, None], 4, axis=1)
delayed_real = np.empty_like(branch_real)
delayed_imag = np.empty_like(branch_imag)
for branch, delay in enumerate(self.delays):
delayed_real[:, branch] = self.memory_real[
(self.position - int(delay)) % self.capacity, :, branch]
delayed_imag[:, branch] = self.memory_imag[
(self.position - int(delay)) % self.capacity, :, branch]
branch_real += np.einsum(
"bj,ij->bi", delayed_real, self.matrix,
dtype=np.float64, optimize=False)
branch_imag += np.einsum(
"bj,ij->bi", delayed_imag, self.matrix,
dtype=np.float64, optimize=False)
tap_index = (self.position - self.model.table_a_integer) % self.capacity
tap_real = self.memory_real[tap_index].copy()
tap_imag = self.memory_imag[tap_index].copy()
next_real = branch_real * self.feedback_real - branch_imag * self.feedback_imag
next_imag = branch_imag * self.feedback_real + branch_real * self.feedback_imag
self.memory_real[self.position] = next_real
self.memory_imag[self.position] = next_imag
self.position = (self.position + 1) % self.capacity
extra_real = np.zeros_like(branch_real)
extra_imag = np.zeros_like(branch_imag)
for index, delay in enumerate(self.model.table_a_extra_indices):
source_real = self.memory_real[
(self.position - (int(delay) + 1)) % self.capacity]
source_imag = self.memory_imag[
(self.position - (int(delay) + 1)) % self.capacity]
matrix = self.extra_matrices[index]
mixed_real = np.einsum(
"bj,ij->bi", source_real, matrix,
dtype=np.float64, optimize=False)
mixed_imag = np.einsum(
"bj,ij->bi", source_imag, matrix,
dtype=np.float64, optimize=False)
coefficient_real = np.empty(self.bands, dtype=np.float64)
coefficient_imag = np.empty(self.bands, dtype=np.float64)
for band in range(self.bands):
group, lane = divmod(band, 4)
coefficient_real[band] = self.extra_fields[index, group, 0, lane]
coefficient_imag[band] = self.extra_fields[index, group, 1, lane]
extra_real += (mixed_real * coefficient_real[:, None]
- mixed_imag * coefficient_imag[:, None])
extra_imag += (mixed_imag * coefficient_real[:, None]
+ mixed_real * coefficient_imag[:, None])
output_real = tap_real * self.output_tap + extra_real
output_imag = tap_imag * self.output_tap + extra_imag
left = np.sum(
self.left_real * output_real - self.left_imag * output_imag,
axis=1, dtype=np.float64)
left_imag = np.sum(
self.left_imag * output_real + self.left_real * output_imag,
axis=1, dtype=np.float64)
right = np.sum(
self.right_real * output_real - self.right_imag * output_imag,
axis=1, dtype=np.float64)
right_imag = np.sum(
self.right_imag * output_real + self.right_real * output_imag,
axis=1, dtype=np.float64)
result = np.zeros((2, 77), dtype=np.complex128)
result[0, :self.bands] = left + 1j * left_imag
result[1, :self.bands] = right + 1j * right_imag
return result
class RosellaRoomFir:
"""Complex128 overlap-add room FIR generated locally from table-A."""
def __init__(self, model: RosellaModel, impulse_slots: int = 4096):
if impulse_slots <= 0:
raise ValueError("impulse_slots must be positive")
reference = _RosellaRoomState(model)
self.length = int(impulse_slots)
self.kernel = np.empty((self.length, 2, 64), dtype=np.complex128)
for slot in range(self.length):
impulse = np.zeros(77, dtype=np.complex128)
if slot == 0:
impulse[:64] = 1.0
self.kernel[slot] = reference.process_slot(impulse)[:, :64]
self.tail = np.zeros((self.length - 1, 2, 64), dtype=np.complex128)
self._fft_cache: dict[int, np.ndarray] = {}
def reset(self):
self.tail.fill(0.0)
def process_chunk(self, room_send) -> np.ndarray:
values = np.asarray(room_send, dtype=np.complex128)
if values.ndim != 2 or values.shape[1] != 77:
raise ValueError("room_send must have shape [slots,77]")
count = len(values)
if count == 0:
return np.zeros((0, 2, 77), dtype=np.complex128)
needed = count + self.length - 1
fft_size = 1 << (needed - 1).bit_length()
kernel_fft = self._fft_cache.get(fft_size)
if kernel_fft is None:
kernel_fft = np.fft.fft(self.kernel, fft_size, axis=0)
self._fft_cache[fft_size] = kernel_fft
input_fft = np.fft.fft(values[:, :64], fft_size, axis=0)
block = np.fft.ifft(input_fft[:, None, :] * kernel_fft, axis=0)[:needed]
block[:len(self.tail)] += self.tail
result = np.zeros((count, 2, 77), dtype=np.complex128)
result[:, :, :64] = block[:count]
self.tail = block[count:count + self.length - 1].copy()
return result
+552
View File
@@ -0,0 +1,552 @@
"""Stateful public SOFA binaural renderer.
The runtime topology mirrors the existing multi-object binaural path:
64-QMF -> 77 hybrid -> per-object directional transfer -> stereo synthesis.
The HRTF parameter source is a SOFA-derived fifth-order field. Early
reflections and the late room use project-owned behavior.
"""
from __future__ import annotations
from dataclasses import dataclass
import math
from pathlib import Path
import numpy as np
from public_filterbank import (
ANALYSIS_SYNTHESIS_LATENCY_SAMPLES,
HYBRID_BANDS,
QMF_HOP,
PublicAnalysis77,
PublicSynthesis77,
table_info as filterbank_table_info,
)
from public_room import (
LateFdnConfig,
SharedUnitaryFdn,
ShoeboxRoomConfig,
first_order_image_sources,
)
from reference_distance import DistanceState, ReferenceDistanceProfileV1
from sofa_canonical import CanonicalHrtf
from sofa_hrtf_field import (
DEFAULT_ORDER,
DEFAULT_PROJECTION_RIDGE,
DEFAULT_SH_RIDGE,
SofaHrtfField,
compile_sofa_hrtf,
)
@dataclass(frozen=True)
class HybridPath:
label: str
delay_slots: np.ndarray # whole-QMF delay per ear, [2]
transfer: np.ndarray # [ear,77], includes residual delay and HRTF delay
def __post_init__(self):
slots = np.asarray(self.delay_slots)
if slots.shape == ():
slots = np.repeat(slots, 2)
if slots.shape != (2,) or slots.dtype.kind not in "iu":
raise ValueError("hybrid path delay_slots must contain two integers")
slots = np.asarray(slots, dtype=np.int64)
transfer = np.asarray(self.transfer, dtype=np.complex128)
if transfer.shape != (2, HYBRID_BANDS) or not np.isfinite(transfer).all():
raise ValueError("hybrid path transfer must have finite shape [2,77]")
if np.any(slots < 0):
raise ValueError("hybrid path delay_slots must be non-negative")
slots.setflags(write=False)
transfer.setflags(write=False)
object.__setattr__(self, "delay_slots", slots)
object.__setattr__(self, "transfer", transfer)
class HybridObjectPathRenderer:
"""Per-object hybrid histories for direct and image-source paths."""
def __init__(self, source_count: int, *, history_slots: int = 256,
transition_slots: int = 8):
self.source_count = int(source_count)
self.history_slots = int(history_slots)
self.transition_slots = int(transition_slots)
if min(self.source_count, self.history_slots) <= 0 or self.transition_slots < 0:
raise ValueError("invalid hybrid path renderer dimensions")
self.history = np.zeros(
(self.source_count, self.history_slots, HYBRID_BANDS), dtype=np.complex128)
self.position = 0
self.current: list[tuple[HybridPath, ...]] = [tuple() for _ in range(self.source_count)]
self.target: list[tuple[HybridPath, ...] | None] = [None] * self.source_count
self.fade_position = np.zeros(self.source_count, dtype=np.int32)
self.fade_total = np.zeros(self.source_count, dtype=np.int32)
self.processed_slots = 0
def reset(self) -> None:
self.history.fill(0.0)
self.position = 0
self.target = [None] * self.source_count
self.fade_position.fill(0)
self.fade_total.fill(0)
self.processed_slots = 0
def set_paths(self, source: int, paths, *, fade_slots: int | None = None) -> None:
source = int(source)
if not 0 <= source < self.source_count:
raise IndexError(source)
values = tuple(paths)
for path in values:
if np.any(path.delay_slots >= self.history_slots):
raise ValueError(
f"path {path.label!r} needs {path.delay_slots.tolist()} slots, "
f"history capacity is {self.history_slots}")
fade = self.transition_slots if fade_slots is None else int(fade_slots)
if fade < 0:
raise ValueError("path fade must be non-negative")
if self.target[source] is not None:
# Normal 512-sample updates complete an 8-slot transition exactly.
# If a caller updates faster, use the previous target as the new
# stable side rather than resetting signal history.
self.current[source] = self.target[source]
self.target[source] = None
if not self.processed_slots or fade == 0:
self.current[source] = values
self.target[source] = None
self.fade_position[source] = 0
self.fade_total[source] = 0
else:
self.target[source] = values
self.fade_position[source] = 0
self.fade_total[source] = fade
def _render_paths(self, source: int, paths: tuple[HybridPath, ...]) -> np.ndarray:
result = np.zeros((2, HYBRID_BANDS), dtype=np.complex128)
for path in paths:
indices = (self.position - path.delay_slots) % self.history_slots
delayed = self.history[source, indices, :]
result += delayed * path.transfer
return result
def process(self, hybrid) -> np.ndarray:
values = np.asarray(hybrid, dtype=np.complex128)
if values.ndim != 3 or values.shape[1:] != (self.source_count, HYBRID_BANDS):
raise ValueError(
f"hybrid input must have shape [slots,{self.source_count},77]")
if not np.isfinite(values).all():
raise ValueError("hybrid input contains non-finite values")
output = np.zeros((len(values), 2, HYBRID_BANDS), dtype=np.complex128)
for slot in range(len(values)):
self.history[:, self.position, :] = values[slot]
for source in range(self.source_count - 1, -1, -1):
current = self._render_paths(source, self.current[source])
target_paths = self.target[source]
if target_paths is None:
output[slot] += current
continue
target = self._render_paths(source, target_paths)
self.fade_position[source] += 1
amount = min(
1.0, self.fade_position[source] / float(self.fade_total[source]))
output[slot] += current * (1.0 - amount) + target * amount
if self.fade_position[source] >= self.fade_total[source]:
self.current[source] = target_paths
self.target[source] = None
self.fade_position[source] = 0
self.fade_total[source] = 0
self.position = (self.position + 1) % self.history_slots
self.processed_slots += 1
return output
class _StereoDelay:
def __init__(self, delay_samples: int):
self.delay_samples = int(delay_samples)
if self.delay_samples < 0:
raise ValueError("delay must be non-negative")
self.state = np.zeros((self.delay_samples, 2), dtype=np.float64)
def reset(self) -> None:
self.state.fill(0.0)
def process(self, values) -> np.ndarray:
source = np.asarray(values, dtype=np.float64)
if source.ndim != 2 or source.shape[1] != 2:
raise ValueError("stereo delay input must have shape [samples,2]")
if self.delay_samples == 0:
return source.copy()
joined = np.concatenate((self.state, source), axis=0)
output = joined[:len(source)].copy()
self.state = joined[len(source):len(source) + self.delay_samples].copy()
return output
class SofaBinauralBackend:
"""SOFA-derived public 77-band/SH renderer with public room processing."""
def __init__(
self,
field: SofaHrtfField,
*,
source_count: int = 16,
sample_rate_hz: float = 48000.0,
default_profile: str = "mid",
enable_early_reflections: bool = True,
enable_late_room: bool = True,
room_config: ShoeboxRoomConfig = ShoeboxRoomConfig(),
fdn_config: LateFdnConfig | None = None,
transition_slots: int = 8,
history_slots: int = 256,
output_gain: float = 1.0):
self.source_count = int(source_count)
self.sample_rate_hz = float(sample_rate_hz)
self.default_profile = ReferenceDistanceProfileV1.validate_profile(default_profile)
self.enable_early_reflections = bool(enable_early_reflections)
self.enable_late_room = bool(enable_late_room)
self.room_config = room_config
self.room_config.validate()
self.output_gain = float(output_gain)
if (self.source_count <= 0 or not math.isfinite(self.sample_rate_hz)
or self.sample_rate_hz <= 0.0):
raise ValueError("source_count and sample rate must be positive")
if not math.isfinite(self.output_gain):
raise ValueError("output gain must be finite")
if abs(self.sample_rate_hz - 48000.0) > 1.0e-9:
raise ValueError("the public binaural runtime requires 48 kHz")
if not isinstance(field, SofaHrtfField):
raise TypeError(
"field must be SofaHrtfField; use from_sofa() or "
"from_compiled_cache() for file inputs")
self.field = field
self.hrtf_input_kind = "field"
self.hrtf_input_path: str | None = None
self.cache_policy: str | None = None
if abs(self.field.sample_rate_hz - self.sample_rate_hz) > 1.0e-9:
raise ValueError("HRTF field sample rate does not match the renderer")
self.early_history_slots = int(history_slots)
if self.early_history_slots <= 0:
raise ValueError("history_slots must be positive")
maximum_hrtf_delay = float(np.max(self.field.delay_bounds[:, 1], initial=0.0))
self.maximum_hrtf_delay_samples = maximum_hrtf_delay
self.hrtf_history_slots = int(math.ceil(maximum_hrtf_delay / QMF_HOP))
self.analysis = PublicAnalysis77(self.source_count)
self.paths = HybridObjectPathRenderer(
self.source_count,
history_slots=self.early_history_slots + self.hrtf_history_slots,
transition_slots=transition_slots)
self.synthesis = PublicSynthesis77(2)
actual_fdn_config = fdn_config or LateFdnConfig(sample_rate_hz=self.sample_rate_hz)
if abs(actual_fdn_config.sample_rate_hz - self.sample_rate_hz) > 1.0e-9:
raise ValueError("FDN sample rate does not match the renderer")
self.fdn = SharedUnitaryFdn(actual_fdn_config)
self.late_delay = _StereoDelay(ANALYSIS_SYNTHESIS_LATENCY_SAMPLES)
self.positions = np.zeros((self.source_count, 3), dtype=np.float64)
self.positions[:, 1] = 1.0
self.profiles = [self.default_profile] * self.source_count
self.user_gain = np.ones(self.source_count, dtype=np.float64)
self.special_lfe = np.zeros(self.source_count, dtype=bool)
self.distance_state: list[DistanceState | None] = [None] * self.source_count
self.late_current = np.zeros(self.source_count, dtype=np.float64)
self.late_start = np.zeros(self.source_count, dtype=np.float64)
self.late_target = np.zeros(self.source_count, dtype=np.float64)
self.late_fade_position = np.zeros(self.source_count, dtype=np.int64)
self.late_fade_total = np.zeros(self.source_count, dtype=np.int64)
self.maximum_early_delay_samples = 0.0
self.latency_to_discard = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
self.processed_input_samples = 0
self.output_samples = 0
self.parameter_updates = 0
self.finished = False
for source in range(self.source_count):
self.set_source(
source, self.positions[source], profile=self.default_profile,
fade=False)
@classmethod
def from_sofa(
cls, sofa: str | Path | CanonicalHrtf, *,
cache_policy: str = "memory",
cache_dir: str | Path | None = None,
shell_radius_m: float = 1.0,
order: int = DEFAULT_ORDER,
projection_ridge: float = DEFAULT_PROJECTION_RIDGE,
sh_ridge: float = DEFAULT_SH_RIDGE,
**renderer_options) -> "SofaBinauralBackend":
"""Compile a SOFA source once and construct the runtime renderer."""
field = compile_sofa_hrtf(
sofa,
target_sample_rate_hz=float(
renderer_options.get("sample_rate_hz", 48000.0)),
shell_radius_m=shell_radius_m,
order=order,
projection_ridge=projection_ridge,
sh_ridge=sh_ridge,
cache_policy=cache_policy,
cache_dir=cache_dir)
result = cls(field, **renderer_options)
result.hrtf_input_kind = "sofa"
result.hrtf_input_path = (
str(Path(sofa).expanduser().resolve())
if not isinstance(sofa, CanonicalHrtf) else sofa.source_path)
result.cache_policy = str(cache_policy).lower()
return result
@classmethod
def from_compiled_cache(
cls, cache: str | Path, **renderer_options
) -> "SofaBinauralBackend":
"""Load an explicitly selected validated JOC compiled HRTF cache."""
path = Path(cache).expanduser().resolve()
field = SofaHrtfField.load(path)
result = cls(field, **renderer_options)
result.hrtf_input_kind = "compiled_cache"
result.hrtf_input_path = str(path)
result.cache_policy = None
return result
def _make_path(self, label: str, direction_adm, path_distance_m: float,
extra_delay_samples: float, amplitude: float) -> HybridPath:
del path_distance_m
evaluation = self.field.evaluate_adm(direction_adm)
extra_delay = float(extra_delay_samples)
if not math.isfinite(extra_delay) or extra_delay < 0.0:
raise ValueError("path delay must be finite and non-negative")
early_delay_slots = int(math.floor(extra_delay / QMF_HOP))
if early_delay_slots >= self.early_history_slots:
raise ValueError(
f"path {label!r} needs {early_delay_slots} early-delay slots, "
f"early history capacity is {self.early_history_slots}")
total_delay = np.asarray(evaluation.delay_samples, dtype=np.float64) + extra_delay
delay_slots = np.floor(total_delay / QMF_HOP).astype(np.int64)
residual = total_delay - delay_slots * QMF_HOP
propagation_phase = np.exp(
-2j * np.pi * self.field.band_center_frequencies_hz[None, :]
* residual[:, None]
/ self.sample_rate_hz)
transfer = np.asarray(
evaluation.aligned_gains * propagation_phase * float(amplitude),
dtype=np.complex128)
return HybridPath(label, delay_slots, transfer)
def _ordinary_paths(self, state: DistanceState, gain: float) -> tuple[HybridPath, ...]:
# Object PCM is programme-normalized. Physical distance controls room
# geometry, while the project profile supplies the direct presentation
# coefficient instead of applying a second free-field 1/r attenuation.
direct_amplitude = gain * ReferenceDistanceProfileV1.direct_level_gain(state)
room_gain = ReferenceDistanceProfileV1.room_calibration_gain(state)
paths = [self._make_path(
"direct", state.direction_adm, state.physical_distance_m, 0.0,
direct_amplitude)]
if self.enable_early_reflections:
reflections = first_order_image_sources(
state.direction_adm, state.physical_distance_m,
self.sample_rate_hz, self.room_config)
for reflection in reflections:
amplitude = (
gain * room_gain * reflection.reflection_gain
* ReferenceDistanceProfileV1.inverse_distance_gain(
self.field.measurement_radius_m,
reflection.path_distance_m))
paths.append(self._make_path(
f"early:{reflection.wall}", reflection.direction_adm,
reflection.path_distance_m, reflection.extra_delay_samples,
amplitude))
self.maximum_early_delay_samples = max(
self.maximum_early_delay_samples,
reflection.extra_delay_samples)
return tuple(paths)
def _lfe_paths(self, gain: float) -> tuple[HybridPath, ...]:
frequency = self.field.band_center_frequencies_hz
lowpass = np.ones(HYBRID_BANDS, dtype=np.float64)
lowpass[frequency >= 180.0] = 0.0
transition = (frequency > 120.0) & (frequency < 180.0)
amount = (frequency[transition] - 120.0) / 60.0
lowpass[transition] = np.cos(0.5 * np.pi * amount) ** 2
transfer = np.repeat(
(gain * lowpass / math.sqrt(2.0))[None, :], 2, axis=0
).astype(np.complex128)
return (HybridPath(
"public_lfe_lowpass", np.zeros(2, dtype=np.int64), transfer),)
def _set_late_target(self, source: int, value: float, fade: bool) -> None:
value = float(value)
fade_samples = (self.paths.transition_slots * QMF_HOP
if fade and self.processed_input_samples else 0)
if fade_samples == 0:
self.late_current[source] = value
self.late_start[source] = value
self.late_target[source] = value
self.late_fade_position[source] = 0
self.late_fade_total[source] = 0
else:
self.late_start[source] = self.late_current[source]
self.late_target[source] = value
self.late_fade_position[source] = 0
self.late_fade_total[source] = fade_samples
def set_source(self, source: int, position_adm, *, profile: str | None = None,
gain: float = 1.0, enabled: bool = True,
special_lfe: bool = False, fade: bool = True) -> None:
if self.finished:
raise RuntimeError("SOFA renderer is finished")
source = int(source)
if not 0 <= source < self.source_count:
raise IndexError(source)
gain = float(gain)
if not math.isfinite(gain):
raise ValueError("source gain must be finite")
effective_gain = gain if enabled else 0.0
name = self.default_profile if profile is None else profile
state = ReferenceDistanceProfileV1.map_adm_position(position_adm, name)
path_set = (self._lfe_paths(effective_gain) if special_lfe
else self._ordinary_paths(state, effective_gain))
self.paths.set_paths(
source, path_set,
fade_slots=(self.paths.transition_slots if fade else 0))
late_send = (0.0 if special_lfe or not self.enable_late_room or not enabled
else effective_gain
* ReferenceDistanceProfileV1.room_calibration_gain(state)
* ReferenceDistanceProfileV1.late_send(state))
self._set_late_target(source, late_send, fade)
self.positions[source] = np.asarray(position_adm, dtype=np.float64)
self.profiles[source] = state.profile
self.user_gain[source] = gain
self.special_lfe[source] = bool(special_lfe)
self.distance_state[source] = state
self.parameter_updates += 1
def _late_send_envelope(self, sample_count: int) -> np.ndarray:
envelope = np.empty((sample_count, self.source_count), dtype=np.float64)
for source in range(self.source_count):
total = int(self.late_fade_total[source])
if total == 0:
envelope[:, source] = self.late_current[source]
continue
start_position = int(self.late_fade_position[source])
position = start_position + np.arange(1, sample_count + 1)
amount = np.clip(position / float(total), 0.0, 1.0)
envelope[:, source] = (
self.late_start[source] * (1.0 - amount)
+ self.late_target[source] * amount)
new_position = start_position + sample_count
if new_position >= total:
self.late_current[source] = self.late_target[source]
self.late_start[source] = self.late_target[source]
self.late_fade_position[source] = 0
self.late_fade_total[source] = 0
else:
self.late_current[source] = float(envelope[-1, source])
self.late_fade_position[source] = new_position
return envelope
def _process(self, sources) -> np.ndarray:
values = np.asarray(sources, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != self.source_count:
raise ValueError(f"sources must have shape [samples,{self.source_count}]")
if len(values) % QMF_HOP:
raise ValueError("SOFA backend input must be divisible by 64 samples")
if not np.isfinite(values).all():
raise ValueError("SOFA backend input contains non-finite values")
hybrid = self.analysis.process(values)
direct_and_early = self.paths.process(hybrid)
direct_pcm = self.synthesis.process(direct_and_early)
if self.enable_late_room:
sends = self._late_send_envelope(len(values))
mono = np.sum(values * sends, axis=1, dtype=np.float64)
late_pcm = self.late_delay.process(self.fdn.process(mono))
else:
# Still advance any pending send fade deterministically.
self._late_send_envelope(len(values))
late_pcm = np.zeros_like(direct_pcm)
mixed = np.asarray((direct_pcm + late_pcm) * self.output_gain, dtype=np.float64)
skip = min(self.latency_to_discard, len(mixed))
self.latency_to_discard -= skip
self.processed_input_samples += len(values)
output = mixed[skip:]
self.output_samples += len(output)
return output
def process(self, sources) -> np.ndarray:
if self.finished:
raise RuntimeError("SOFA renderer is finished")
return self._process(sources)
def finish(self, *, tail_seconds: float | None = None) -> np.ndarray:
if self.finished:
return np.zeros((0, 2), dtype=np.float64)
if tail_seconds is not None and (
not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0):
raise ValueError("tail_seconds must be finite and non-negative")
requested = (self.fdn.tail_samples if tail_seconds is None
else int(math.ceil(float(tail_seconds) * self.sample_rate_hz)))
drain = max(
requested if self.enable_late_room else 0,
int(math.ceil(
self.maximum_hrtf_delay_samples
+ self.maximum_early_delay_samples)) + 2048,
) + ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
drain = int(math.ceil(drain / QMF_HOP) * QMF_HOP)
output = self._process(np.zeros((drain, self.source_count), dtype=np.float64))
self.finished = True
return output
def finish_output_capacity(self, tail_seconds: float | None = None) -> int:
"""Return a conservative bound for one future :meth:`finish` output."""
if tail_seconds is not None and (
not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0):
raise ValueError("tail_seconds must be finite and non-negative")
requested = (self.fdn.tail_samples if tail_seconds is None
else int(math.ceil(float(tail_seconds) * self.sample_rate_hz)))
hrtf_bound = self.hrtf_history_slots * QMF_HOP
early_bound = hrtf_bound + 2048
if self.enable_early_reflections:
early_bound += self.early_history_slots * QMF_HOP
drain = max(requested if self.enable_late_room else 0, early_bound)
drain += ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
return int(math.ceil(drain / QMF_HOP) * QMF_HOP)
def reset(self) -> None:
self.analysis.reset()
self.paths.reset()
self.synthesis.reset()
self.fdn.reset()
self.late_delay.reset()
self.latency_to_discard = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
self.processed_input_samples = 0
self.output_samples = 0
self.finished = False
for source in range(self.source_count):
self.set_source(
source, self.positions[source], profile=self.profiles[source],
gain=float(self.user_gain[source]),
special_lfe=bool(self.special_lfe[source]), fade=False)
def info(self) -> dict:
return {
"name": "SofaBinauralBackend",
"source_count": self.source_count,
"sample_rate_hz": self.sample_rate_hz,
"precision": "float64/complex128",
"signal_path": (
"public 64-QMF -> public 77-hybrid -> SOFA order-5 real-SH "
"direct/early -> public synthesis + shared unitary FDN"),
"hrtf_input_kind": self.hrtf_input_kind,
"hrtf_input_path": self.hrtf_input_path,
"cache_policy": self.cache_policy,
"latency_compensated_samples": ANALYSIS_SYNTHESIS_LATENCY_SAMPLES,
"enable_early_reflections": self.enable_early_reflections,
"enable_late_room": self.enable_late_room,
"early_history_slots": self.early_history_slots,
"hrtf_history_slots": self.hrtf_history_slots,
"maximum_hrtf_delay_samples": self.maximum_hrtf_delay_samples,
"maximum_early_delay_samples": self.maximum_early_delay_samples,
"parameter_updates": self.parameter_updates,
"processed_input_samples_including_flush": self.processed_input_samples,
"output_samples_before_trim": self.output_samples,
"distance": ReferenceDistanceProfileV1.info(),
"filterbank": filterbank_table_info(),
"field": self.field.info(),
"late_room": self.fdn.info(),
}
+637
View File
@@ -0,0 +1,637 @@
"""Strict SimpleFreeFieldHRIR to canonical HRTF import.
The canonical representation keeps ``Data.IR`` and ``Data.Delay`` separate.
No importer operation silently bakes the SOFA delay into the stored FIRs. A
caller must explicitly request :meth:`CanonicalHrtf.materialized_measurement`
when a time-domain FIR with ``Data.Delay`` applied exactly once is required.
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, replace
from fractions import Fraction
import hashlib
import math
from pathlib import Path
from typing import Any
import h5py
import numpy as np
from scipy import signal
from scipy.fft import next_fast_len
_SUPPORTED_VERSIONS = {"0.4", "1.0", "1.1"}
_FREE_FIELD_ROOM_TYPES = {"free field", "free-field", "anechoic", "hemi-anechoic"}
_LENGTH_UNITS = {
"m": 1.0,
"metre": 1.0,
"metres": 1.0,
"meter": 1.0,
"meters": 1.0,
"cm": 1.0e-2,
"centimetre": 1.0e-2,
"centimetres": 1.0e-2,
"centimeter": 1.0e-2,
"centimeters": 1.0e-2,
"mm": 1.0e-3,
"millimetre": 1.0e-3,
"millimetres": 1.0e-3,
"millimeter": 1.0e-3,
"millimeters": 1.0e-3,
}
_ANGLE_UNITS = {
"degree": np.deg2rad,
"degrees": np.deg2rad,
"radian": lambda value: np.asarray(value, dtype=np.float64),
"radians": lambda value: np.asarray(value, dtype=np.float64),
}
class SofaImportError(ValueError):
"""The file is outside the deliberately narrow public SOFA contract."""
def _text(value: Any) -> str:
if isinstance(value, np.ndarray) and value.shape == ():
value = value.item()
if isinstance(value, (bytes, np.bytes_)):
return value.decode("utf-8", "strict")
return str(value)
def _sha256_stream(stream) -> str:
digest = hashlib.sha256()
stream.seek(0)
for block in iter(lambda: stream.read(4 << 20), b""):
digest.update(block)
stream.seek(0)
return digest.hexdigest().upper()
@contextmanager
def _stable_hdf5_source(path: Path):
"""Read arrays and content identity from one stable open-file snapshot."""
with path.open("rb") as stream:
before = _sha256_stream(stream)
with h5py.File(stream, "r") as file:
yield file, before
after = _sha256_stream(stream)
if after != before:
raise SofaImportError("SOFA file changed while it was being imported")
def _tokens(units: str) -> list[str]:
return [token.strip().lower() for token in units.split(",") if token.strip()]
def _coordinate_attributes(dataset: h5py.Dataset, *, inherit=None) -> tuple[str, str]:
source = dataset.attrs
if "Type" not in source or "Units" not in source:
if inherit is None or "Type" not in inherit.attrs or "Units" not in inherit.attrs:
raise SofaImportError(f"{dataset.name} must declare Type and Units")
source = inherit.attrs
return _text(source["Type"]).strip().lower(), _text(source["Units"]).strip()
def coordinates_to_cartesian_m(values, coordinate_type: str, units: str,
*, variable: str) -> np.ndarray:
"""Convert a SOFA coordinate array to Cartesian metres without reshaping it."""
data = np.asarray(values, dtype=np.float64)
if data.shape[-1] != 3 or not np.isfinite(data).all():
raise SofaImportError(f"{variable} must contain finite C=3 coordinates")
kind = coordinate_type.strip().lower()
unit_tokens = _tokens(units)
if kind == "cartesian":
if len(unit_tokens) == 1:
factors = [_LENGTH_UNITS.get(unit_tokens[0])] * 3
elif len(unit_tokens) == 3:
factors = [_LENGTH_UNITS.get(token) for token in unit_tokens]
else:
factors = []
if len(factors) != 3 or any(value is None for value in factors):
raise SofaImportError(f"unsupported Cartesian units for {variable}: {units!r}")
return data * np.asarray(factors, dtype=np.float64)
if kind != "spherical" or len(unit_tokens) != 3:
raise SofaImportError(
f"unsupported coordinates for {variable}: Type={coordinate_type!r}, Units={units!r}")
if unit_tokens[0] not in _ANGLE_UNITS or unit_tokens[1] not in _ANGLE_UNITS:
raise SofaImportError(f"unsupported spherical angle units for {variable}: {units!r}")
radius_factor = _LENGTH_UNITS.get(unit_tokens[2])
if radius_factor is None:
raise SofaImportError(f"unsupported spherical radius unit for {variable}: {units!r}")
azimuth = _ANGLE_UNITS[unit_tokens[0]](data[..., 0])
elevation = _ANGLE_UNITS[unit_tokens[1]](data[..., 1])
radius = data[..., 2] * radius_factor
if np.any(radius < 0.0):
raise SofaImportError(f"{variable} contains a negative spherical radius")
horizontal = np.cos(elevation)
return np.stack(
(radius * horizontal * np.cos(azimuth),
radius * horizontal * np.sin(azimuth),
radius * np.sin(elevation)),
axis=-1,
).astype(np.float64, copy=False)
def _rows(file: h5py.File, name: str, measurements: int, *, inherit=None) -> np.ndarray:
if name not in file:
raise SofaImportError(f"missing required SOFA variable {name}")
dataset = file[name]
kind, units = _coordinate_attributes(dataset, inherit=inherit)
result = coordinates_to_cartesian_m(dataset[...], kind, units, variable=name)
if result.shape not in ((1, 3), (measurements, 3)):
raise SofaImportError(
f"{name} must have shape [I,C] or [M,C], got {result.shape}")
return np.broadcast_to(result, (measurements, 3)).astype(np.float64, copy=True)
def _receiver_rows(file: h5py.File, measurements: int) -> np.ndarray:
if "ReceiverPosition" not in file:
raise SofaImportError("missing required SOFA variable ReceiverPosition")
dataset = file["ReceiverPosition"]
raw = np.asarray(dataset[...], dtype=np.float64)
if raw.shape not in ((2, 3, 1), (2, 3, measurements)):
raise SofaImportError(
"ReceiverPosition must have shape [R=2,C=3,I=1 or M]")
values = np.moveaxis(raw, 1, -1) # [R,I/M,C]
kind, units = _coordinate_attributes(dataset)
cartesian = coordinates_to_cartesian_m(
values, kind, units, variable="ReceiverPosition")
cartesian = np.moveaxis(cartesian, 0, 1) # [I/M,R,C]
return np.broadcast_to(cartesian, (measurements, 2, 3)).astype(
np.float64, copy=True)
def _emitter_is_origin(file: h5py.File, measurements: int) -> None:
if "EmitterPosition" not in file:
raise SofaImportError("missing required SOFA variable EmitterPosition")
dataset = file["EmitterPosition"]
raw = np.asarray(dataset[...], dtype=np.float64)
if raw.shape not in ((1, 3, 1), (1, 3, measurements)):
raise SofaImportError("SimpleFreeFieldHRIR v1 requires E=1 EmitterPosition[E,C,I/M]")
values = np.moveaxis(raw, 1, -1)
kind, units = _coordinate_attributes(dataset)
cartesian = coordinates_to_cartesian_m(
values, kind, units, variable="EmitterPosition")
if np.max(np.abs(cartesian), initial=0.0) > 1.0e-9:
raise SofaImportError("non-zero EmitterPosition needs a separate source-pose adapter")
def _sampling_rate(file: h5py.File) -> float:
if "Data.SamplingRate" not in file:
raise SofaImportError("missing Data.SamplingRate")
dataset = file["Data.SamplingRate"]
values = np.asarray(dataset[...], dtype=np.float64).reshape(-1)
if values.size != 1 or not math.isfinite(float(values[0])) or values[0] <= 0.0:
raise SofaImportError("Data.SamplingRate must contain one positive finite value")
units = _text(dataset.attrs.get("Units", "")).strip().lower()
if units not in {"hertz", "hz"}:
raise SofaImportError(f"Data.SamplingRate Units must be hertz, got {units!r}")
return float(values[0])
def _processing_label(file: h5py.File) -> str:
parts = []
for key in ("DatabaseName", "Title", "ListenerShortName", "Comment"):
value = _text(file.attrs.get(key, "")).strip()
if value and value not in parts:
parts.append(value)
return " | ".join(parts)
def _read_delay(file: h5py.File, measurements: int) -> np.ndarray:
if "Data.Delay" not in file:
raise SofaImportError("missing Data.Delay")
delay = np.asarray(file["Data.Delay"][...], dtype=np.float64)
if delay.shape not in ((1, 2), (measurements, 2)) or not np.isfinite(delay).all():
raise SofaImportError("Data.Delay must have finite shape [I=1,R=2] or [M,R=2]")
delay = np.broadcast_to(delay, (measurements, 2)).astype(np.float64, copy=True)
if np.min(delay, initial=0.0) < -1.0e-9:
raise SofaImportError("negative Data.Delay is outside the supported causal contract")
delay[delay < 0.0] = 0.0
return delay
def _readonly(array, dtype) -> np.ndarray:
result = np.asarray(array, dtype=dtype)
result.setflags(write=False)
return result
@dataclass(frozen=True)
class CanonicalHrtf:
source_path: str
source_sha256: str
convention: str
convention_version: str
sofa_version: str
source_sample_rate_hz: float
sample_rate_hz: float
source_position_cartesian_m: np.ndarray # listener-local [M,3]
listener_view: np.ndarray # world, normalized [M,3]
listener_up: np.ndarray # world, orthonormal [M,3]
receiver_position_cartesian_m: np.ndarray # listener-local, L/R [M,2,3]
left_receiver_index: int
right_receiver_index: int
hrir: np.ndarray # canonical L/R [M,2,N]
delay_samples: np.ndarray # canonical L/R [M,2], not applied
measurement_radius_m: np.ndarray # [M]
processing_label: str
resampling_label: str = "none"
def __post_init__(self):
object.__setattr__(self, "source_position_cartesian_m", _readonly(
self.source_position_cartesian_m, np.float64))
object.__setattr__(self, "listener_view", _readonly(self.listener_view, np.float64))
object.__setattr__(self, "listener_up", _readonly(self.listener_up, np.float64))
object.__setattr__(self, "receiver_position_cartesian_m", _readonly(
self.receiver_position_cartesian_m, np.float64))
object.__setattr__(self, "hrir", _readonly(self.hrir, np.float64))
object.__setattr__(self, "delay_samples", _readonly(self.delay_samples, np.float64))
object.__setattr__(self, "measurement_radius_m", _readonly(
self.measurement_radius_m, np.float64))
@property
def measurements(self) -> int:
return int(self.hrir.shape[0])
@property
def taps(self) -> int:
return int(self.hrir.shape[2])
@property
def unit_directions(self) -> np.ndarray:
return self.source_position_cartesian_m / self.measurement_radius_m[:, None]
@property
def shells_m(self) -> np.ndarray:
return np.unique(np.round(self.measurement_radius_m, 9))
def shell_indices(self, radius_m: float) -> np.ndarray:
shell = float(self.shells_m[np.argmin(np.abs(self.shells_m - float(radius_m)))])
return np.flatnonzero(np.isclose(
self.measurement_radius_m, shell, atol=5.0e-7, rtol=0.0))
def nearest_index(self, direction_sofa, radius_m: float = 1.0) -> tuple[int, float]:
direction = np.asarray(direction_sofa, dtype=np.float64)
if direction.shape != (3,) or not np.isfinite(direction).all():
raise ValueError("direction must contain three finite SOFA Cartesian values")
norm = float(np.linalg.norm(direction))
if norm <= 1.0e-15:
raise ValueError("direction must be non-zero")
direction = direction / norm
indices = self.shell_indices(radius_m)
dots = self.unit_directions[indices] @ direction
local = int(np.argmax(dots))
error = math.degrees(math.acos(float(np.clip(dots[local], -1.0, 1.0))))
return int(indices[local]), float(error)
def resampled(self, target_sample_rate_hz: float) -> "CanonicalHrtf":
target = float(target_sample_rate_hz)
if not math.isfinite(target) or target <= 0.0:
raise ValueError("target sample rate must be positive and finite")
if abs(target - self.sample_rate_hz) <= 1.0e-9:
return self
ratio = target / self.sample_rate_hz
fraction = Fraction(ratio).limit_denominator(100000)
if abs(float(fraction) - ratio) > 1.0e-10:
raise ValueError("sample-rate ratio cannot be represented safely")
converted = signal.resample_poly(
np.asarray(self.hrir, dtype=np.float64), fraction.numerator,
fraction.denominator, axis=-1, window=("kaiser", 8.6), padtype="constant")
converted = np.asarray(converted, dtype=np.float64)
return replace(
self,
sample_rate_hz=target,
hrir=converted,
delay_samples=np.asarray(self.delay_samples * ratio, dtype=np.float64),
resampling_label=(
f"scipy.signal.resample_poly {self.sample_rate_hz:g}->{target:g} Hz "
f"({fraction.numerator}/{fraction.denominator}, Kaiser beta=8.6)"),
)
def materialized_measurement(self, index: int, *, fractional_half_length: int = 48
) -> np.ndarray:
"""Return [L/R,taps] with SOFA Data.Delay applied exactly once."""
index = int(index)
if not 0 <= index < self.measurements:
raise IndexError(index)
ears = []
for ear in range(2):
ears.append(apply_fractional_delay(
self.hrir[index, ear], float(self.delay_samples[index, ear]),
half_length=fractional_half_length))
length = max(map(len, ears))
result = np.zeros((2, length), dtype=np.float64)
for ear, value in enumerate(ears):
result[ear, :len(value)] = value
return result
def info(self) -> dict:
return {
"source_path": self.source_path,
"source_sha256": self.source_sha256,
"convention": self.convention,
"convention_version": self.convention_version,
"sofa_version": self.sofa_version,
"source_sample_rate_hz": self.source_sample_rate_hz,
"sample_rate_hz": self.sample_rate_hz,
"measurements": self.measurements,
"taps": self.taps,
"shells_m": [float(value) for value in self.shells_m],
"source_receiver_order": [self.left_receiver_index, self.right_receiver_index],
"canonical_ear_order": ["left", "right"],
"data_delay_samples_min": float(np.min(self.delay_samples)),
"data_delay_samples_max": float(np.max(self.delay_samples)),
"data_delay_applied": False,
"processing_label": self.processing_label,
"resampling": self.resampling_label,
"precision": "float64",
}
def load_simple_free_field_hrir(path, *, target_sample_rate_hz: float | None = None
) -> CanonicalHrtf:
"""Strictly import the supported SimpleFreeFieldHRIR subset."""
source = Path(path).expanduser().resolve()
if not source.is_file():
raise FileNotFoundError(source)
with _stable_hdf5_source(source) as (file, source_sha256):
if _text(file.attrs.get("Conventions", "")) != "SOFA":
raise SofaImportError("Conventions must be SOFA")
convention = _text(file.attrs.get("SOFAConventions", ""))
if convention != "SimpleFreeFieldHRIR":
raise SofaImportError(
f"unsupported SOFAConventions={convention!r}; convert explicitly first")
convention_version = _text(file.attrs.get("SOFAConventionsVersion", ""))
if convention_version not in _SUPPORTED_VERSIONS:
raise SofaImportError(
f"unsupported SimpleFreeFieldHRIR version {convention_version!r}; "
f"supported={sorted(_SUPPORTED_VERSIONS)}")
if _text(file.attrs.get("DataType", "")) != "FIR":
raise SofaImportError("DataType must be FIR")
room_type = _text(file.attrs.get("RoomType", "")).strip().lower()
if room_type not in _FREE_FIELD_ROOM_TYPES:
raise SofaImportError(f"RoomType must explicitly be free-field, got {room_type!r}")
if "Data.IR" not in file:
raise SofaImportError("missing Data.IR")
hrir_source = np.asarray(file["Data.IR"][...], dtype=np.float64)
if hrir_source.ndim != 3 or hrir_source.shape[1] != 2 or min(hrir_source.shape) <= 0:
raise SofaImportError("Data.IR must have shape [M,R=2,N]")
if not np.isfinite(hrir_source).all():
raise SofaImportError("Data.IR contains non-finite values")
measurements = int(hrir_source.shape[0])
sample_rate = _sampling_rate(file)
delay_source = _read_delay(file, measurements)
_emitter_is_origin(file, measurements)
listener_position = _rows(file, "ListenerPosition", measurements)
listener_view = _rows(file, "ListenerView", measurements)
listener_up_raw = _rows(
file, "ListenerUp", measurements,
inherit=file["ListenerView"] if "ListenerView" in file else None)
forward_norm = np.linalg.norm(listener_view, axis=1)
if np.any(forward_norm <= 1.0e-12):
raise SofaImportError("ListenerView must be non-zero")
forward = listener_view / forward_norm[:, None]
left = np.cross(listener_up_raw, forward)
left_norm = np.linalg.norm(left, axis=1)
if np.any(left_norm <= 1.0e-12):
raise SofaImportError("ListenerUp must not be parallel to ListenerView")
left /= left_norm[:, None]
up = np.cross(forward, left)
if "SourcePosition" not in file:
raise SofaImportError("missing required SOFA variable SourcePosition")
source_dataset = file["SourcePosition"]
source_type, source_units = _coordinate_attributes(source_dataset)
source_world = coordinates_to_cartesian_m(
source_dataset[...], source_type, source_units, variable="SourcePosition")
if source_world.shape != (measurements, 3):
raise SofaImportError("SourcePosition must have shape [M,C=3]")
relative = source_world - listener_position
source_local = np.stack(
(np.sum(relative * forward, axis=1),
np.sum(relative * left, axis=1),
np.sum(relative * up, axis=1)), axis=1)
radii = np.linalg.norm(source_local, axis=1)
if np.any(radii <= 1.0e-8) or not np.isfinite(radii).all():
raise SofaImportError("every source measurement must have a positive radius")
receiver = _receiver_rows(file, measurements)
lateral_difference = receiver[:, 0, 1] - receiver[:, 1, 1]
if np.all(lateral_difference > 1.0e-5):
left_index, right_index = 0, 1
elif np.all(lateral_difference < -1.0e-5):
left_index, right_index = 1, 0
else:
raise SofaImportError(
"ReceiverPosition does not identify one consistently-left and one "
"consistently-right receiver")
ear_order = [left_index, right_index]
hrir = hrir_source[:, ear_order, :]
delay = delay_source[:, ear_order]
receiver = receiver[:, ear_order, :]
processing_label = _processing_label(file)
sofa_version = _text(file.attrs.get("Version", ""))
canonical = CanonicalHrtf(
source_path=str(source),
source_sha256=source_sha256,
convention=convention,
convention_version=convention_version,
sofa_version=sofa_version,
source_sample_rate_hz=sample_rate,
sample_rate_hz=sample_rate,
source_position_cartesian_m=source_local,
listener_view=forward,
listener_up=up,
receiver_position_cartesian_m=receiver,
left_receiver_index=left_index,
right_receiver_index=right_index,
hrir=hrir,
delay_samples=delay,
measurement_radius_m=radii,
processing_label=processing_label,
)
return (canonical if target_sample_rate_hz is None
else canonical.resampled(target_sample_rate_hz))
def apply_fractional_delay(values, delay_samples: float, *, half_length: int = 48
) -> np.ndarray:
"""Apply one causal non-negative delay to a real FIR using windowed sinc."""
source = np.asarray(values, dtype=np.float64)
delay = float(delay_samples)
if source.ndim != 1 or not np.isfinite(source).all():
raise ValueError("fractional delay input must be a finite real vector")
if not math.isfinite(delay) or delay < -1.0e-12:
raise ValueError("fractional delay must be finite and non-negative")
if delay < 1.0e-12:
return source.copy()
integer = int(math.floor(delay))
fraction = delay - integer
if fraction < 1.0e-12:
return np.pad(source, (integer, 0)).astype(np.float64, copy=False)
half = int(half_length)
if half < 8:
raise ValueError("fractional delay half_length must be at least 8")
index = np.arange(-half, half + 1, dtype=np.float64)
kernel = np.sinc(index - fraction) * np.kaiser(2 * half + 1, 8.6)
kernel /= np.sum(kernel, dtype=np.float64)
full = signal.fftconvolve(source, kernel, mode="full")
causal = np.asarray(full[half:], dtype=np.float64)
return np.pad(causal, (integer, 0)).astype(np.float64, copy=False)
def shift_signal_fft(values, shift_samples: float) -> np.ndarray:
"""Band-limited linear shift; positive is delay and negative is advance."""
source = np.asarray(values, dtype=np.float64)
shift = float(shift_samples)
if source.ndim != 1 or not np.isfinite(source).all() or not math.isfinite(shift):
raise ValueError("shift input and amount must be finite")
if abs(shift) < 1.0e-12:
return source.copy()
guard = max(128, int(math.ceil(abs(shift))) + 64)
needed = len(source) + 2 * guard
fft_size = next_fast_len(needed)
padded = np.zeros(fft_size, dtype=np.float64)
padded[guard:guard + len(source)] = source
bins = np.arange(fft_size // 2 + 1, dtype=np.float64)
spectrum = np.fft.rfft(padded)
spectrum *= np.exp(-2j * np.pi * bins * shift / fft_size)
shifted = np.fft.irfft(spectrum, fft_size)
return np.asarray(shifted[guard:guard + len(source)], dtype=np.float64)
def estimate_interaural_delay_samples(left, right, sample_rate_hz: float,
*, low_hz: float = 200.0,
high_hz: float = 1500.0) -> float:
"""Estimate L-minus-R delay by low-frequency circular phase coherence.
A coarse-to-fine delay search avoids the phase-unwrapping branch failures
that ordinary straight-line regression can exhibit for strongly filtered
Far responses.
"""
left = np.asarray(left, dtype=np.float64)
right = np.asarray(right, dtype=np.float64)
if left.shape != right.shape or left.ndim != 1:
raise ValueError("ITD inputs must be equal-length vectors")
fft_size = next_fast_len(max(4096, 4 * len(left)))
left_spectrum = np.fft.rfft(left, fft_size)
right_spectrum = np.fft.rfft(right, fft_size)
frequency = np.fft.rfftfreq(fft_size, 1.0 / float(sample_rate_hz))
selected = (frequency >= low_hz) & (frequency <= high_hz)
if np.count_nonzero(selected) < 8:
return 0.0
cross = left_spectrum[selected] * np.conj(right_spectrum[selected])
magnitude = np.abs(cross)
maximum = float(np.max(magnitude, initial=0.0))
if maximum <= 1.0e-20:
return 0.0
weighted_unit = cross / np.maximum(magnitude, 1.0e-30)
weight = np.sqrt(magnitude / maximum)
weighted_unit *= weight
omega = 2.0 * np.pi * frequency[selected] / float(sample_rate_hz)
limit = 0.0012 * float(sample_rate_hz)
def best(candidates: np.ndarray) -> float:
steering = np.exp(1j * omega[:, None] * candidates[None, :])
score = np.abs(weighted_unit @ steering)
return float(candidates[int(np.argmax(score))])
coarse = np.arange(-limit, limit + 0.25, 0.5, dtype=np.float64)
estimate = best(coarse)
fine = np.arange(estimate - 0.6, estimate + 0.6001, 0.02, dtype=np.float64)
return float(np.clip(best(fine), -limit, limit))
def _subsample_peak(values: np.ndarray) -> float:
magnitude = np.abs(np.asarray(values, dtype=np.float64))
index = int(np.argmax(magnitude))
if index == 0 or index + 1 >= len(magnitude):
return float(index)
y0, y1, y2 = (float(magnitude[index - 1]), float(magnitude[index]),
float(magnitude[index + 1]))
denominator = y0 - 2.0 * y1 + y2
correction = 0.0 if abs(denominator) < 1.0e-30 else 0.5 * (y0 - y2) / denominator
return float(index + np.clip(correction, -0.5, 0.5))
@dataclass(frozen=True)
class TimeAlignedHrtf:
canonical: CanonicalHrtf
aligned_hrir: np.ndarray
runtime_delay_samples: np.ndarray
embedded_delay_removed_samples: np.ndarray
delay_source: str
def __post_init__(self):
object.__setattr__(self, "aligned_hrir", _readonly(self.aligned_hrir, np.float64))
object.__setattr__(self, "runtime_delay_samples", _readonly(
self.runtime_delay_samples, np.float64))
object.__setattr__(self, "embedded_delay_removed_samples", _readonly(
self.embedded_delay_removed_samples, np.float64))
def time_align_hrtf(canonical: CanonicalHrtf) -> TimeAlignedHrtf:
"""Separate one delay representation before directional interpolation.
Trusted non-zero ``Data.Delay`` is external to ``Data.IR`` and is therefore
retained without de-rotating the FIR. When ``Data.Delay`` is identically
zero, ordinary measured HRIRs with a positive onset use their per-ear main
peaks. A zero-origin effective FIR is already expressed at one common
time origin; its interaural phase is therefore retained in ``Data.IR``.
These representations are mutually exclusive. Runtime rendering must
restore exactly the delay separated here and must not add any second ear
delay or phase-group delay.
"""
hrir = np.asarray(canonical.hrir, dtype=np.float64)
if np.max(np.abs(canonical.delay_samples), initial=0.0) > 1.0e-12:
return TimeAlignedHrtf(
canonical=canonical,
aligned_hrir=hrir.copy(),
runtime_delay_samples=np.asarray(canonical.delay_samples, dtype=np.float64),
embedded_delay_removed_samples=np.zeros_like(canonical.delay_samples),
delay_source="Data.Delay (external; applied once at render time)",
)
measurements = canonical.measurements
runtime = np.zeros((measurements, 2), dtype=np.float64)
removed = np.zeros_like(runtime)
used_peak = 0
retained_embedded_phase = 0
for measurement in range(measurements):
peaks = np.asarray([
_subsample_peak(hrir[measurement, 0]),
_subsample_peak(hrir[measurement, 1]),
], dtype=np.float64)
if float(np.max(peaks)) > 2.0:
delays = peaks
used_peak += 1
else:
# An effective response can have both ear FIRs beginning at sample
# zero while still carrying the correct ITD in complex phase. Do
# not invent an external delay which SOFA did not author.
delays = np.zeros(2, dtype=np.float64)
retained_embedded_phase += 1
runtime[measurement] = delays
removed[measurement] = delays
aligned = np.empty_like(hrir)
for measurement in range(measurements):
for ear in range(2):
aligned[measurement, ear] = shift_signal_fft(
hrir[measurement, ear], -float(removed[measurement, ear]))
source = (
f"embedded Data.IR arrival separation: peak={used_peak}, "
f"zero-origin embedded phase retained={retained_embedded_phase}; "
"positive onset restored once at render time")
return TimeAlignedHrtf(
canonical=canonical,
aligned_hrir=aligned,
runtime_delay_samples=runtime,
embedded_delay_removed_samples=removed,
delay_source=source,
)
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
"""Native float64 SOFA binaural DSP (ctypes bridge to eac3joc_core).
The C++ side mirrors the Python :class:`sofa_binaural_backend.SofaBinauralBackend`
mathematics: 64-QMF/77-hybrid analysis and synthesis, fifth-order ACN/N3D real
spherical-harmonic field evaluation, whole-QMF-slot per-object delay histories,
six image-source early reflections, the shared unitary FDN late room, the LFE
low-pass and the 961-sample latency policy. The compiled HRTF field, the
filterbank tables and the room constants are uploaded once; per 512-sample
block the adapter updates every source and streams PCM through the DLL.
"""
from __future__ import annotations
import ctypes
import math
from pathlib import Path
import numpy as np
from native_renderer import ABI_VERSION, find_native_library
from public_filterbank import DEFAULT_FILTERBANK_DATA, load_filterbank_tables
from public_room import LateFdnConfig, SharedUnitaryFdn, ShoeboxRoomConfig
from reference_distance import ReferenceDistanceProfileV1
from sofa_binaural_backend import SofaBinauralBackend
from sofa_hrtf_field import (
DEFAULT_HRTF_CACHE_DIR,
SofaHrtfField,
compile_sofa_hrtf,
)
BLOCK_SAMPLES = 512
INPUT_CHANNELS = 16
OUTPUT_CHANNELS = 2
QMF_HOP = 64
LATENCY_SAMPLES = 961
_PROFILE_INDEX = {"near": 0, "mid": 1, "far": 2}
def _room_numbers(fdn_config: LateFdnConfig) -> dict:
"""Derive the FDN delays/feedback with the same arithmetic as the Python room."""
fdn = SharedUnitaryFdn(fdn_config)
return {
"fdn_delays": np.asarray(fdn.delays, dtype=np.uint32),
"fdn_feedback": np.asarray(fdn.feedback_gain, dtype=np.float64),
"damping": fdn.damping,
"output_gain": fdn.output_gain,
"allpass_delays": np.asarray(
[diffuser.delay_samples for diffuser in fdn.diffusers], dtype=np.uint32),
"allpass_gains": np.asarray(fdn_config.allpass_gain, dtype=np.float64),
"tail_samples": fdn.tail_samples,
}
class NativeSofaBinauralDsp:
"""Duck-type compatible with SofaBinauralBackend for the JOC adapter."""
def __init__(
self,
field: SofaHrtfField,
*,
source_count: int = INPUT_CHANNELS,
default_profile: str = "mid",
enable_early_reflections: bool = True,
enable_late_room: bool = True,
room_config: ShoeboxRoomConfig = ShoeboxRoomConfig(),
fdn_config: LateFdnConfig | None = None,
library_path: str | Path | None = None):
if not isinstance(field, SofaHrtfField):
raise TypeError("field must be SofaHrtfField")
self.source_count = int(source_count)
self.default_profile = ReferenceDistanceProfileV1.validate_profile(default_profile)
self.enable_early_reflections = bool(enable_early_reflections)
self.enable_late_room = bool(enable_late_room)
self.field = field
if self.source_count != INPUT_CHANNELS:
raise ValueError(f"native SOFA backend requires {INPUT_CHANNELS} sources")
if abs(self.field.sample_rate_hz - 48000.0) > 1.0e-9:
raise ValueError("the native SOFA binaural runtime requires 48 kHz")
self.room_config = room_config
self.room_config.validate()
self.dsp_backend = "native-sofa"
self.hrtf_input_kind = "field"
self.hrtf_input_path = None
self.cache_policy = None
self.library_path = find_native_library(library_path)
self._lib = ctypes.CDLL(str(self.library_path))
self._bind()
version = int(self._lib.ejoc_abi_version())
if version != ABI_VERSION:
raise RuntimeError(
f"native ABI mismatch: expected {ABI_VERSION}, got {version}")
self._handle = self._lib.ejoc_sofa_binaural_create()
if not self._handle:
raise RuntimeError("native SOFA binaural renderer creation failed")
try:
self._configure_kernels()
self._configure_field()
self._configure_room(fdn_config)
except Exception:
self.close()
raise
self.positions = np.zeros((self.source_count, 3), dtype=np.float64)
self.positions[:, 1] = 1.0
self.profiles = [self.default_profile] * self.source_count
self.user_gain = np.ones(self.source_count, dtype=np.float64)
self.special_lfe = np.zeros(self.source_count, dtype=bool)
self.parameter_updates = 0
self.finished = False
for source in range(self.source_count):
self.set_source(
source, self.positions[source], profile=self.default_profile,
fade=False)
def _bind(self):
void_p = ctypes.c_void_p
f64_p = ctypes.POINTER(ctypes.c_double)
i16_p = ctypes.POINTER(ctypes.c_int16)
u32_p = ctypes.POINTER(ctypes.c_uint32)
self._lib.ejoc_abi_version.argtypes = []
self._lib.ejoc_abi_version.restype = ctypes.c_uint32
self._lib.ejoc_sofa_binaural_create.argtypes = []
self._lib.ejoc_sofa_binaural_create.restype = void_p
self._lib.ejoc_sofa_binaural_destroy.argtypes = [void_p]
self._lib.ejoc_sofa_binaural_destroy.restype = None
self._lib.ejoc_sofa_binaural_reset.argtypes = [void_p]
self._lib.ejoc_sofa_binaural_reset.restype = ctypes.c_int
self._lib.ejoc_sofa_binaural_last_error.argtypes = [void_p]
self._lib.ejoc_sofa_binaural_last_error.restype = ctypes.c_char_p
self._lib.ejoc_sofa_binaural_configure_kernels.argtypes = [
void_p, f64_p, f64_p, i16_p, f64_p, ctypes.c_uint32, f64_p, f64_p]
self._lib.ejoc_sofa_binaural_configure_kernels.restype = ctypes.c_int
self._lib.ejoc_sofa_binaural_configure_field.argtypes = [
void_p, f64_p, f64_p, f64_p, f64_p, ctypes.c_double]
self._lib.ejoc_sofa_binaural_configure_field.restype = ctypes.c_int
self._lib.ejoc_sofa_binaural_configure_room.argtypes = [
void_p, f64_p, f64_p, f64_p, ctypes.c_double, u32_p, f64_p,
ctypes.c_double, ctypes.c_double, u32_p, f64_p,
ctypes.c_uint32, ctypes.c_uint32]
self._lib.ejoc_sofa_binaural_configure_room.restype = ctypes.c_int
self._lib.ejoc_sofa_binaural_set_source.argtypes = [
void_p, ctypes.c_uint32, f64_p, ctypes.c_uint32, ctypes.c_double,
ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32]
self._lib.ejoc_sofa_binaural_set_source.restype = ctypes.c_int
self._lib.ejoc_sofa_binaural_process.argtypes = [
void_p, f64_p, ctypes.c_uint32, ctypes.c_double, f64_p]
self._lib.ejoc_sofa_binaural_process.restype = ctypes.c_int
self._lib.ejoc_sofa_binaural_finish.argtypes = [
void_p, ctypes.c_uint32, f64_p, ctypes.c_uint32]
self._lib.ejoc_sofa_binaural_finish.restype = ctypes.c_int
def _raise(self, operation, status):
message = self._lib.ejoc_sofa_binaural_last_error(self._handle)
detail = (message or b"").decode("utf-8", "replace")
raise RuntimeError(
f"native SOFA binaural renderer {operation} failed ({status}): {detail}")
@staticmethod
def _f64_pointer(values):
return values.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
def _configure_kernels(self):
tables = load_filterbank_tables(DEFAULT_FILTERBANK_DATA)
qmf_analysis = np.ascontiguousarray(
tables["qmf_analysis_coefficients"], dtype=np.float64)
hybrid_low = np.ascontiguousarray(
tables["hybrid_analysis_low_kernel"], dtype=np.float64)
hybrid_indices = np.ascontiguousarray(
tables["hybrid_synthesis_indices"], dtype=np.int16)
hybrid_values = np.ascontiguousarray(
tables["hybrid_synthesis_values"], dtype=np.float64)
qmf_basis = np.ascontiguousarray(
tables["qmf_synthesis_basis"], dtype=np.float64)
qmf_taps = np.ascontiguousarray(
tables["qmf_synthesis_taps"], dtype=np.float64)
status = self._lib.ejoc_sofa_binaural_configure_kernels(
self._handle,
self._f64_pointer(qmf_analysis),
self._f64_pointer(hybrid_low),
hybrid_indices.ctypes.data_as(ctypes.POINTER(ctypes.c_int16)),
self._f64_pointer(hybrid_values),
len(hybrid_indices),
self._f64_pointer(qmf_basis),
self._f64_pointer(qmf_taps))
if status:
self._raise("configure_kernels", status)
self._keepalive = (qmf_analysis, hybrid_low, hybrid_indices,
hybrid_values, qmf_basis, qmf_taps)
def _configure_field(self):
coefficients = np.ascontiguousarray(
self.field.coefficients, dtype=np.complex128).view(np.float64)
delay_coefficients = np.ascontiguousarray(
self.field.delay_coefficients, dtype=np.float64)
delay_bounds = np.ascontiguousarray(
self.field.delay_bounds, dtype=np.float64)
centers = np.ascontiguousarray(
self.field.band_center_frequencies_hz, dtype=np.float64)
status = self._lib.ejoc_sofa_binaural_configure_field(
self._handle,
self._f64_pointer(coefficients),
self._f64_pointer(delay_coefficients),
self._f64_pointer(delay_bounds),
self._f64_pointer(centers),
float(self.field.measurement_radius_m))
if status:
self._raise("configure_field", status)
def _configure_room(self, fdn_config: LateFdnConfig | None):
actual = fdn_config or LateFdnConfig(sample_rate_hz=48000.0)
numbers = _room_numbers(actual)
dims = np.asarray(self.room_config.dimensions_m, dtype=np.float64)
listener = np.asarray(self.room_config.listener_position_m, dtype=np.float64)
walls = np.asarray(self.room_config.wall_reflection_gain, dtype=np.float64)
status = self._lib.ejoc_sofa_binaural_configure_room(
self._handle,
self._f64_pointer(dims),
self._f64_pointer(listener),
self._f64_pointer(walls),
float(self.room_config.speed_of_sound_m_s),
numbers["fdn_delays"].ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)),
self._f64_pointer(numbers["fdn_feedback"]),
float(numbers["damping"]),
float(numbers["output_gain"]),
numbers["allpass_delays"].ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)),
self._f64_pointer(numbers["allpass_gains"]),
1 if self.enable_early_reflections else 0,
1 if self.enable_late_room else 0)
if status:
self._raise("configure_room", status)
self._fdn = SharedUnitaryFdn(actual)
self._fdn_tail_samples = numbers["tail_samples"]
def set_source(self, source: int, position_adm, *, profile: str | None = None,
gain: float = 1.0, enabled: bool = True,
special_lfe: bool = False, fade: bool = True) -> None:
if self.finished:
raise RuntimeError("SOFA renderer is finished")
source = int(source)
if not 0 <= source < self.source_count:
raise IndexError(source)
name = self.default_profile if profile is None else profile
position = np.asarray(position_adm, dtype=np.float64)
if position.shape != (3,):
raise ValueError("ADM position must contain three Cartesian values")
status = self._lib.ejoc_sofa_binaural_set_source(
self._handle, source,
self._f64_pointer(np.ascontiguousarray(position)),
_PROFILE_INDEX[ReferenceDistanceProfileV1.validate_profile(name)],
float(gain), 1 if enabled else 0, 1 if special_lfe else 0,
1 if fade else 0)
if status:
self._raise("set_source", status)
self.positions[source] = position
self.profiles[source] = name
self.user_gain[source] = float(gain)
self.special_lfe[source] = bool(special_lfe)
self.parameter_updates += 1
def process(self, sources) -> np.ndarray:
if self.finished:
raise RuntimeError("SOFA renderer is finished")
values = np.ascontiguousarray(sources, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != self.source_count:
raise ValueError(f"sources must have shape [samples,{self.source_count}]")
if len(values) % QMF_HOP or len(values) > BLOCK_SAMPLES:
raise ValueError("native SOFA backend input must be a 64-aligned block")
output = np.empty((len(values), OUTPUT_CHANNELS), dtype=np.float64)
count = self._lib.ejoc_sofa_binaural_process(
self._handle, self._f64_pointer(values), len(values), 1.0,
self._f64_pointer(output))
if count < 0:
self._raise("process", count)
return output[:count]
def finish(self, *, tail_seconds: float | None = None) -> np.ndarray:
if self.finished:
return np.zeros((0, OUTPUT_CHANNELS), dtype=np.float64)
if tail_seconds is not None and (
not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0):
raise ValueError("tail_seconds must be finite and non-negative")
flush = self.finish_output_capacity(tail_seconds)
pieces = []
remaining = flush
while remaining > 0:
chunk = min(BLOCK_SAMPLES, remaining)
output = np.empty((chunk, OUTPUT_CHANNELS), dtype=np.float64)
count = self._lib.ejoc_sofa_binaural_finish(
self._handle, chunk, self._f64_pointer(output), chunk)
if count < 0:
self._raise("finish", count)
pieces.append(output[:count])
remaining -= chunk
self.finished = True
nonempty = [piece for piece in pieces if len(piece)]
if not nonempty:
return np.zeros((0, OUTPUT_CHANNELS), dtype=np.float64)
return np.concatenate(nonempty, axis=0)
def finish_output_capacity(self, tail_seconds: float | None = None) -> int:
if tail_seconds is not None and (
not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0):
raise ValueError("tail_seconds must be finite and non-negative")
requested = (self._fdn_tail_samples if tail_seconds is None
else int(math.ceil(float(tail_seconds) * 48000.0)))
maximum_hrtf = float(np.max(self.field.delay_bounds[:, 1], initial=0.0))
hrtf_slots = int(math.ceil(maximum_hrtf / QMF_HOP))
hrtf_bound = hrtf_slots * QMF_HOP
early_bound = hrtf_bound + 2048
if self.enable_early_reflections:
early_bound += 256 * QMF_HOP
drain = max(requested if self.enable_late_room else 0, early_bound)
drain += LATENCY_SAMPLES
return int(math.ceil(drain / QMF_HOP) * QMF_HOP)
def reset(self) -> None:
if self._lib.ejoc_sofa_binaural_reset(self._handle):
self._raise("reset", -1)
self.finished = False
for source in range(self.source_count):
self.set_source(
source, self.positions[source], profile=self.profiles[source],
gain=float(self.user_gain[source]),
special_lfe=bool(self.special_lfe[source]), fade=False)
def info(self) -> dict:
return {
"name": "NativeSofaBinauralDsp",
"source_count": self.source_count,
"sample_rate_hz": 48000.0,
"precision": "float64/complex128",
"signal_path": (
"native 64-QMF -> native 77-hybrid -> SOFA order-5 real-SH "
"direct/early -> native synthesis + shared unitary FDN"),
"hrtf_input_kind": self.hrtf_input_kind,
"hrtf_input_path": self.hrtf_input_path,
"cache_policy": self.cache_policy,
"latency_compensated_samples": LATENCY_SAMPLES,
"enable_early_reflections": self.enable_early_reflections,
"enable_late_room": self.enable_late_room,
"early_history_slots": 256,
"hrtf_history_slots": int(math.ceil(
float(np.max(self.field.delay_bounds[:, 1], initial=0.0)) / QMF_HOP)),
"maximum_hrtf_delay_samples": float(
np.max(self.field.delay_bounds[:, 1], initial=0.0)),
"parameter_updates": self.parameter_updates,
"distance": ReferenceDistanceProfileV1.info(),
"field": self.field.info(),
"library_path": str(self.library_path),
"native_backend": True,
}
def close(self) -> None:
handle = getattr(self, "_handle", None)
if handle:
self._lib.ejoc_sofa_binaural_destroy(handle)
self._handle = None
self.finished = True
def create_native_sofa_renderer(
sofa, *, mode="mid", cache_policy="memory", cache_dir=None,
shell_radius_m=1.0, object_delay_samples=1473, tail_seconds=5.0,
output_gain=1.0, chunk_frames=64):
"""Compile a SOFA source and build a JOC adapter over the native DSP."""
from binaural_renderer import SofaBinauralRenderer, resolve_sofa_hrtf
source = resolve_sofa_hrtf(sofa)
field = compile_sofa_hrtf(
source,
shell_radius_m=shell_radius_m,
cache_policy=cache_policy,
cache_dir=cache_dir)
backend = NativeSofaBinauralDsp(field, default_profile=mode)
backend.hrtf_input_kind = "sofa"
backend.hrtf_input_path = str(source)
backend.cache_policy = str(cache_policy).lower()
return SofaBinauralRenderer(
backend, mode=mode, object_delay_samples=object_delay_samples,
tail_seconds=tail_seconds, chunk_frames=chunk_frames)
def create_native_compiled_cache_renderer(
cache, *, mode="mid", object_delay_samples=1473, tail_seconds=5.0,
output_gain=1.0, chunk_frames=64):
"""Load a compiled cache and build a JOC adapter over the native DSP."""
from binaural_renderer import SofaBinauralRenderer, resolve_compiled_hrtf_cache
source = resolve_compiled_hrtf_cache(cache)
field = SofaHrtfField.load(source)
backend = NativeSofaBinauralDsp(field, default_profile=mode)
backend.hrtf_input_kind = "compiled_cache"
backend.hrtf_input_path = str(source)
backend.cache_policy = None
return SofaBinauralRenderer(
backend, mode=mode, object_delay_samples=object_delay_samples,
tail_seconds=tail_seconds, chunk_frames=chunk_frames)
+103 -29
View File
@@ -1,6 +1,7 @@
"""Streaming spool and WAV writer for direct speaker-layout output."""
"""Shared PCM spool, peak analysis, and WAV writer for direct outputs."""
from __future__ import annotations
import math
import struct
from pathlib import Path
@@ -13,48 +14,115 @@ _PCM_GUID = bytes.fromhex("0100000000001000800000aa00389b71")
_FLOAT_GUID = bytes.fromhex("0300000000001000800000aa00389b71")
class SpeakerPcmSpool:
"""Temporary interleaved float32 store with float64 peak analysis."""
class PcmSpool:
"""Temporary interleaved PCM store with float64 peak and clipping analysis.
def __init__(self, path, sample_count, channel_count):
``storage_dtype`` controls only the temporary representation. Speaker
output keeps its historical float32 spool, while binaural uses float64 so
precision is reduced only by the selected final WAV format.
"""
def __init__(self, path, sample_capacity, channel_count, *,
expected_samples=None, storage_dtype="<f4",
tail_threshold=None):
self.path = Path(path)
self.sample_count = int(sample_count)
self.sample_capacity = int(sample_capacity)
self.sample_count = int(
self.sample_capacity if expected_samples is None else expected_samples)
self.expected_samples = (
None if expected_samples is None else int(expected_samples))
self.channel_count = int(channel_count)
self.storage_dtype = np.dtype(storage_dtype)
self.tail_threshold = (
None if tail_threshold is None else float(tail_threshold))
if self.sample_capacity < 0 or self.channel_count <= 0:
raise ValueError("invalid PCM spool dimensions")
if self.expected_samples is not None and not (
0 <= self.expected_samples <= self.sample_capacity):
raise ValueError("expected_samples exceeds sample_capacity")
if self.tail_threshold is not None and (
not math.isfinite(self.tail_threshold) or self.tail_threshold < 0.0):
raise ValueError("tail_threshold must be finite and non-negative")
self.position = 0
self.peak = 0.0
self.clipped_values = 0
self.values = np.memmap(
self.path, dtype="<f4", mode="w+",
shape=(self.sample_count, self.channel_count),
self.last_above_threshold = -1
self.kept_samples = None
self._values = np.memmap(
self.path, dtype=self.storage_dtype, mode="w+",
shape=(self.sample_capacity, self.channel_count),
)
@property
def values(self):
if self._values is None:
raise RuntimeError("PCM spool is closed")
length = (self.position if self.kept_samples is None
else self.kept_samples)
return self._values[:length]
def write_frame(self, pcm):
values = np.asarray(pcm, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != self.channel_count:
raise ValueError(
f"speaker frame must have shape [samples,{self.channel_count}], got {values.shape}")
if self.position + len(values) > self.sample_count:
raise ValueError("speaker spool received more samples than allocated")
f"PCM frame must have shape [samples,{self.channel_count}], got {values.shape}")
if self.position + len(values) > self.sample_capacity:
raise ValueError("PCM spool received more samples than allocated")
if not np.all(np.isfinite(values)):
raise ValueError("speaker renderer produced NaN or infinity")
raise ValueError("renderer produced NaN or infinity")
absolute = np.abs(values)
if absolute.size:
self.peak = max(self.peak, float(np.max(absolute)))
self.clipped_values += int(np.count_nonzero(absolute > 1.0))
self.values[self.position:self.position + len(values)] = values.astype(np.float32)
if self.tail_threshold is not None:
per_sample = np.max(absolute, axis=1)
above = np.flatnonzero(per_sample > self.tail_threshold)
if above.size:
self.last_above_threshold = self.position + int(above[-1])
self._values[self.position:self.position + len(values)] = values.astype(
self.storage_dtype, copy=False)
self.position += len(values)
def finalize(self):
if self.position != self.sample_count:
def finalize(self, *, minimum_samples=0):
if (self.expected_samples is not None
and self.position != self.expected_samples):
raise ValueError(
f"speaker spool has {self.position} samples, expected {self.sample_count}")
self.values.flush()
f"PCM spool has {self.position} samples, expected {self.expected_samples}")
keep = self.position
if self.tail_threshold is not None:
keep = min(
self.position,
max(int(minimum_samples), self.last_above_threshold + 1),
)
self.kept_samples = keep
self.sample_count = keep
self._values.flush()
return self
def close(self):
values = self.values
self.values = None
del values
values = self._values
self._values = None
if values is not None:
del values
class SpeakerPcmSpool(PcmSpool):
"""Backward-compatible fixed-length float32 speaker spool."""
def __init__(self, path, sample_count, channel_count):
super().__init__(
path, sample_count, channel_count,
expected_samples=sample_count, storage_dtype="<f4")
class BinauralPcmSpool(PcmSpool):
"""Float64 variable-tail spool for the binaural renderer."""
def __init__(self, path, sample_capacity, *, tail_threshold=1.0e-8):
super().__init__(
path, sample_capacity, 2,
expected_samples=None, storage_dtype="<f8",
tail_threshold=tail_threshold)
def _fmt_chunk(channel_count, rate, sample_format):
@@ -69,7 +137,7 @@ def _fmt_chunk(channel_count, rate, sample_format):
simple_tag = WAVE_FORMAT_PCM
guid = _PCM_GUID
else:
raise ValueError(f"unsupported speaker WAV format: {sample_format}")
raise ValueError(f"unsupported WAV format: {sample_format}")
block_align = channel_count * bytes_per_sample
byte_rate = rate * block_align
if channel_count <= 2:
@@ -94,13 +162,13 @@ def _write_header(stream, channel_count, sample_count, rate, sample_format):
riff_file_size = 12 + 8 + len(fmt) + 8 + data_size
use_rf64 = riff_file_size - 8 > 0xFFFFFFFF
if use_rf64:
# RF64 + ds64 + fmt + data.
file_size = 12 + 36 + 8 + len(fmt) + 8 + data_size
stream.write(b"RF64")
stream.write(struct.pack("<I", 0xFFFFFFFF))
stream.write(b"WAVE")
stream.write(b"ds64")
stream.write(struct.pack("<IQQQI", 28, file_size - 8, data_size, sample_count, 0))
stream.write(struct.pack(
"<IQQQI", 28, file_size - 8, data_size, sample_count, 0))
else:
stream.write(b"RIFF")
stream.write(struct.pack("<I", riff_file_size - 8))
@@ -121,7 +189,8 @@ def _write_header(stream, channel_count, sample_count, rate, sample_format):
def _pack_int24(values):
scaled = (np.clip(values, -1.0, 1.0) * np.float32(8388607.0)).astype(np.int32)
source = np.asarray(values)
scaled = (np.clip(source, -1.0, 1.0) * np.float32(8388607.0)).astype(np.int32)
unsigned = scaled.reshape(-1).view(np.uint32)
packed = np.empty((unsigned.size, 3), dtype=np.uint8)
packed[:, 0] = unsigned & 0xFF
@@ -130,21 +199,22 @@ def _pack_int24(values):
return packed.tobytes()
def write_speaker_wav(path, pcm, sample_format, *, rate=48000, chunk_samples=262144):
"""Write an interleaved float32 array/memmap as float32 or PCM24 WAV."""
def write_pcm_wav(path, pcm, sample_format, *, rate=48000,
chunk_samples=262144):
"""Write an interleaved array/memmap as float32 or PCM24 WAV."""
target = Path(path)
values = np.asarray(pcm)
if values.ndim != 2:
raise ValueError(f"speaker PCM must be 2D, got {values.shape}")
raise ValueError(f"PCM must be 2D, got {values.shape}")
sample_count, channel_count = values.shape
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as stream:
info = _write_header(
stream, channel_count, sample_count, int(rate), sample_format)
for start in range(0, sample_count, int(chunk_samples)):
block = np.asarray(values[start:start + chunk_samples], dtype="<f4")
block = np.asarray(values[start:start + chunk_samples])
if sample_format == "float32":
stream.write(block.tobytes(order="C"))
stream.write(block.astype("<f4", copy=False).tobytes(order="C"))
else:
stream.write(_pack_int24(block))
info.update({
@@ -155,3 +225,7 @@ def write_speaker_wav(path, pcm, sample_format, *, rate=48000, chunk_samples=262
"file_bytes": target.stat().st_size,
})
return info
# Existing imports remain valid.
write_speaker_wav = write_pcm_wav
+144
View File
@@ -0,0 +1,144 @@
"""Orthonormal real spherical harmonics in ACN order, through fifth order."""
from __future__ import annotations
import math
import numpy as np
from scipy.spatial import SphericalVoronoi
def _associated_legendre(order: int, degree: int, x: np.ndarray) -> np.ndarray:
"""P_degree^order(x), including the Condon-Shortley phase."""
m = int(order)
l = int(degree)
if not 0 <= m <= l:
raise ValueError("associated Legendre indices require 0 <= m <= l")
x = np.asarray(x, dtype=np.float64)
p_mm = np.ones_like(x)
if m:
double_factorial = 1.0
for value in range(1, 2 * m, 2):
double_factorial *= value
p_mm = ((-1.0) ** m) * double_factorial * np.power(
np.maximum(0.0, 1.0 - x * x), 0.5 * m)
if l == m:
return p_mm
p_m1 = x * (2 * m + 1) * p_mm
if l == m + 1:
return p_m1
previous_previous = p_mm
previous = p_m1
for current_degree in range(m + 2, l + 1):
current = (
(2 * current_degree - 1) * x * previous
- (current_degree + m - 1) * previous_previous
) / float(current_degree - m)
previous_previous, previous = previous, current
return previous
def real_spherical_harmonics(directions, order: int = 5) -> np.ndarray:
"""Return [directions,(order+1)^2] ACN/N3D real harmonics.
Coordinates use SOFA listener axes: +X front, +Y left, +Z up. The basis is
orthonormal over the sphere and includes the Condon-Shortley phase.
"""
maximum_order = int(order)
if not 0 <= maximum_order <= 12:
raise ValueError("supported spherical-harmonic orders are 0..12")
vectors = np.asarray(directions, dtype=np.float64)
one = vectors.ndim == 1
if one:
vectors = vectors[None, :]
if vectors.ndim != 2 or vectors.shape[1] != 3 or not np.isfinite(vectors).all():
raise ValueError("directions must have finite shape [M,3]")
length = np.linalg.norm(vectors, axis=1)
if np.any(length <= 1.0e-15):
raise ValueError("spherical-harmonic directions must be non-zero")
unit = vectors / length[:, None]
azimuth = np.arctan2(unit[:, 1], unit[:, 0])
cos_colatitude = np.clip(unit[:, 2], -1.0, 1.0)
result = np.empty((len(unit), (maximum_order + 1) ** 2), dtype=np.float64)
column = 0
for degree in range(maximum_order + 1):
for m in range(-degree, degree + 1):
absolute = abs(m)
normalization = math.sqrt(
(2 * degree + 1) / (4.0 * math.pi)
* math.factorial(degree - absolute)
/ math.factorial(degree + absolute))
legendre = _associated_legendre(absolute, degree, cos_colatitude)
if m < 0:
value = math.sqrt(2.0) * normalization * legendre * np.sin(
absolute * azimuth)
elif m > 0:
value = math.sqrt(2.0) * normalization * legendre * np.cos(
m * azimuth)
else:
value = normalization * legendre
result[:, column] = value
column += 1
return result[0] if one else result
def spherical_voronoi_weights(directions) -> np.ndarray:
"""Area weights for an irregular full-sphere grid, with uniform fallback."""
vectors = np.asarray(directions, dtype=np.float64)
if vectors.ndim != 2 or vectors.shape[1] != 3:
raise ValueError("directions must have shape [M,3]")
unit = vectors / np.linalg.norm(vectors, axis=1)[:, None]
if len(unit) < 4:
return np.full(len(unit), 1.0 / len(unit), dtype=np.float64)
try:
voronoi = SphericalVoronoi(unit, radius=1.0, center=np.zeros(3))
areas = np.asarray(voronoi.calculate_areas(), dtype=np.float64)
if not np.isfinite(areas).all() or np.any(areas <= 0.0):
raise ValueError("invalid spherical Voronoi areas")
return areas / np.sum(areas, dtype=np.float64)
except (ValueError, RuntimeError, np.linalg.LinAlgError):
return np.full(len(unit), 1.0 / len(unit), dtype=np.float64)
def fit_real_spherical_harmonics(directions, values, *, order: int = 5,
ridge: float = 1.0e-6,
weights=None) -> np.ndarray:
"""Weighted ridge fit. Output shape is [terms,...value trailing axes]."""
basis = real_spherical_harmonics(directions, order=order)
target = np.asarray(values)
if target.shape[0] != basis.shape[0]:
raise ValueError("spherical-harmonic target count does not match directions")
if target.dtype.kind == "c":
target = np.asarray(target, dtype=np.complex128)
solve_dtype = np.complex128
else:
target = np.asarray(target, dtype=np.float64)
solve_dtype = np.float64
if weights is None:
weight = spherical_voronoi_weights(directions)
else:
weight = np.asarray(weights, dtype=np.float64)
if weight.shape != (len(basis),) or np.any(weight < 0.0) or not np.isfinite(weight).all():
raise ValueError("weights must be finite non-negative [M]")
total = float(np.sum(weight))
if total <= 0.0:
raise ValueError("weights must have positive sum")
weight = weight / total
flat = target.reshape(len(target), -1)
weighted_basis = basis * weight[:, None]
gram = basis.T @ weighted_basis
regularization = float(ridge)
if not math.isfinite(regularization) or regularization < 0.0:
raise ValueError("ridge must be finite and non-negative")
scale = float(np.trace(gram)) / gram.shape[0]
system = gram + np.eye(gram.shape[0], dtype=np.float64) * regularization * scale
right = basis.T @ (weight[:, None] * flat)
coefficients = np.linalg.solve(system.astype(solve_dtype), right.astype(solve_dtype))
return coefficients.reshape((basis.shape[1],) + target.shape[1:])
def evaluate_real_spherical_harmonics(coefficients, directions,
*, order: int = 5) -> np.ndarray:
basis = real_spherical_harmonics(directions, order=order)
coeff = np.asarray(coefficients)
if coeff.shape[0] != (int(order) + 1) ** 2:
raise ValueError("coefficient term count does not match order")
return np.tensordot(basis, coeff, axes=([-1], [0]))