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
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:
@@ -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]))
|
||||
Reference in New Issue
Block a user