85105f21d4
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
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""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,
|
|
)
|