From dd913c7fe977c4a90a076f1723e340aa7add99dc Mon Sep 17 00:00:00 2001 From: Erik Fleischhauer Date: Wed, 24 Jun 2026 16:01:59 +0200 Subject: [PATCH 1/2] Add orientation parameter to RealSphericalHarmonicsDirectivity object. Add new test. --- CHANGELOG.rst | 5 ++ pyroomacoustics/directivities/harmonics.py | 58 +++++++++---- .../test_harmonics_directivities.py | 81 +++++++++++++++++++ 3 files changed, 127 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ea7a0efe..5abbbc25 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,11 @@ adheres to `Semantic Versioning `_. `Unreleased`_ ------------- +Added +~~~~~~~ + +- Added ``orientation`` parameter to ``RealSphericalHarmonicsDirectivity`` object. + Fixed ~~~~~ diff --git a/pyroomacoustics/directivities/harmonics.py b/pyroomacoustics/directivities/harmonics.py index bd5f6be5..22e44885 100644 --- a/pyroomacoustics/directivities/harmonics.py +++ b/pyroomacoustics/directivities/harmonics.py @@ -52,8 +52,9 @@ import numpy as np import scipy.special -from ..doa import cart2spher +from ..doa import cart2spher, spher2cart from .base import Directivity +from .direction import Rotation3D def get_mn_in_acn_order(degree): @@ -140,16 +141,42 @@ class RealSphericalHarmonicsDirectivity(Directivity): Order of the spherical harmonic. n: int Degree of the spherical harmonic. + orientation: Rotation3D, optional + A rotation to apply to the pattern. If not provided, the + identity rotation is used, i.e., the harmonic is evaluated in + its canonical frame with no reorientation. condon_shortley_phase: bool, optional If True, includes the Condon-Shortley phase factor (-1)^m. Default is False. """ - def __init__(self, m, n, condon_shortley_phase: bool = False): + def __init__(self, m, n, orientation=None, condon_shortley_phase: bool = False): self.m = m self.n = n - self.condon_shortley_phase = condon_shortley_phase + if orientation is None: + orientation = Rotation3D([0.0, 0.0, 0.0]) # identity rotation, no-op default + elif not isinstance(orientation, Rotation3D): + raise TypeError( + f"orientation must be a Rotation3D object, got {type(orientation).__name__}" + ) + self._orientation = orientation + + def set_orientation(self, orientation): + """ + Set orientation of directivity pattern. + + Parameters + ---------- + orientation: Rotation3D + New direction for the directivity pattern. + """ + if not isinstance(orientation, Rotation3D): + raise TypeError( + f"orientation must be a Rotation3D object, got {type(orientation).__name__}" + ) + self._orientation = orientation + @property def is_impulse_response(self): return True @@ -159,26 +186,23 @@ def filter_len_ir(self): return 1 def get_response_cartesian(self, directions, magnitude=False, frequency=None): - az, co, _ = cart2spher(directions.T) - return self.get_response( - az, co, magnitude=magnitude, frequency=frequency, degrees=False - ) + local_dirs = self._orientation.rotate_transpose(directions.T) - def get_response( - self, azimuth, colatitude=None, magnitude=False, frequency=None, degrees=True - ): + azimuth, colatitude, _ = cart2spher(local_dirs) - if degrees: - azimuth = np.radians(azimuth) - colatitude = np.radians(colatitude) return real_sph_harm( - self.n, - self.m, - colatitude, - azimuth, + self.n, self.m, colatitude, azimuth, condon_shortley_phase=self.condon_shortley_phase, )[:, np.newaxis] + def get_response(self, azimuth, colatitude=None, magnitude=False, + frequency=None, degrees=True): + + # world-frame cartesian unit vectors + directions = spher2cart(azimuth, colatitude, degrees=degrees).T + + return self.get_response_cartesian(directions, magnitude=magnitude) + def sample_rays(self, n_rays, rng=None): """Not yet implemented.""" raise NotImplementedError diff --git a/tests/directivities/test_harmonics_directivities.py b/tests/directivities/test_harmonics_directivities.py index 30ea9cc7..d7fdf59a 100644 --- a/tests/directivities/test_harmonics_directivities.py +++ b/tests/directivities/test_harmonics_directivities.py @@ -6,6 +6,24 @@ real_sph_harm, ) +FS = 16000 +MIC_POS = [1.0, 1.2, 1.5] +SRC_POS = [3.5, 2.8, 1.7] +ROOM_DIM = [6.0, 5.0, 3.0] + + +def _simulate_rir(directivity, max_order=0): + """Run a room simulation with a single directional microphone.""" + if max_order == 0: + room = pra.AnechoicRoom(fs=FS, dim=3) + else: + mat = pra.Material(energy_absorption=0.3) + room = pra.ShoeBox(ROOM_DIM, fs=FS, materials=mat, max_order=max_order) + + room.add_source(SRC_POS) + room.add_microphone(MIC_POS, directivity=directivity) + room.compute_rir() + return room.rir[0][0] def test_harmonics_directivity(): """ @@ -38,3 +56,66 @@ def test_harmonics_directivity(): ) np.allclose(rir_sum, sh, atol=1e-2) + +def test_harmonics_rotated_z(): + """ + Check that the `orientation` attribute of `RealSphericalHarmonicsDirectivity` + correctly rotates the pattern about the z-axis: an (m=1, n=1) harmonic + rotated by 90 degrees about z should produce the same RIR as an + unrotated (m=-1, n=1) harmonic at the same position. + """ + max_order = 2 + + rot_90_z = pra.directivities.Rotation3D([90.0], "z", degrees=True) + + dir_rotated = pra.directivities.RealSphericalHarmonicsDirectivity( + m=1, n=1, orientation=rot_90_z + ) + dir_unrotated = pra.directivities.RealSphericalHarmonicsDirectivity(m=-1, n=1) + + rir_rotated = _simulate_rir(dir_rotated, max_order=max_order) + rir_reference = _simulate_rir(dir_unrotated, max_order=max_order) + + np.testing.assert_allclose(rir_rotated, rir_reference, atol=1e-9, rtol=0) + +def test_harmonics_rotated_y(): + """ + Check that the `orientation` attribute of `RealSphericalHarmonicsDirectivity` + correctly rotates the pattern about the y-axis: an (m=0, n=1) harmonic + rotated by 90 degrees about y should produce the same RIR as an + unrotated (m=1, n=1) harmonic at the same position. + """ + max_order = 2 + + rot_90_y = pra.directivities.Rotation3D([90.0], "y", degrees=True) + + dir_rotated = pra.directivities.RealSphericalHarmonicsDirectivity( + m=0, n=1, orientation=rot_90_y + ) + dir_unrotated = pra.directivities.RealSphericalHarmonicsDirectivity(m=1, n=1) + + rir_rotated = _simulate_rir(dir_rotated, max_order=max_order) + rir_reference = _simulate_rir(dir_unrotated, max_order=max_order) + + np.testing.assert_allclose(rir_rotated, rir_reference, atol=1e-9, rtol=0) + +def test_harmonics_rotated_x(): + """ + Check that the `orientation` attribute of `RealSphericalHarmonicsDirectivity` + correctly rotates the pattern about the x-axis: an (m=0, n=1) harmonic + rotated by -90 degrees about x should produce the same RIR as an + unrotated (m=-1, n=1) harmonic at the same position. + """ + max_order = 2 + + rot_m90_x = pra.directivities.Rotation3D([-90.0], "x", degrees=True) + + dir_rotated = pra.directivities.RealSphericalHarmonicsDirectivity( + m=0, n=1, orientation=rot_m90_x + ) + dir_unrotated = pra.directivities.RealSphericalHarmonicsDirectivity(m=-1, n=1) + + rir_rotated = _simulate_rir(dir_rotated, max_order=max_order) + rir_reference = _simulate_rir(dir_unrotated, max_order=max_order) + + np.testing.assert_allclose(rir_rotated, rir_reference, atol=1e-9, rtol=0) From 0bb61c88f301ce6d3e071eea82160e4bde5efb7b Mon Sep 17 00:00:00 2001 From: Erik Fleischhauer Date: Wed, 24 Jun 2026 16:48:25 +0200 Subject: [PATCH 2/2] Use the pre-commit tool to format code correctly. --- pyroomacoustics/directivities/harmonics.py | 14 ++++++++++---- .../directivities/test_harmonics_directivities.py | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pyroomacoustics/directivities/harmonics.py b/pyroomacoustics/directivities/harmonics.py index 22e44885..fec01721 100644 --- a/pyroomacoustics/directivities/harmonics.py +++ b/pyroomacoustics/directivities/harmonics.py @@ -155,7 +155,9 @@ def __init__(self, m, n, orientation=None, condon_shortley_phase: bool = False): self.condon_shortley_phase = condon_shortley_phase if orientation is None: - orientation = Rotation3D([0.0, 0.0, 0.0]) # identity rotation, no-op default + orientation = Rotation3D( + [0.0, 0.0, 0.0] + ) # identity rotation, no-op default elif not isinstance(orientation, Rotation3D): raise TypeError( f"orientation must be a Rotation3D object, got {type(orientation).__name__}" @@ -191,12 +193,16 @@ def get_response_cartesian(self, directions, magnitude=False, frequency=None): azimuth, colatitude, _ = cart2spher(local_dirs) return real_sph_harm( - self.n, self.m, colatitude, azimuth, + self.n, + self.m, + colatitude, + azimuth, condon_shortley_phase=self.condon_shortley_phase, )[:, np.newaxis] - def get_response(self, azimuth, colatitude=None, magnitude=False, - frequency=None, degrees=True): + def get_response( + self, azimuth, colatitude=None, magnitude=False, frequency=None, degrees=True + ): # world-frame cartesian unit vectors directions = spher2cart(azimuth, colatitude, degrees=degrees).T diff --git a/tests/directivities/test_harmonics_directivities.py b/tests/directivities/test_harmonics_directivities.py index d7fdf59a..b82b134a 100644 --- a/tests/directivities/test_harmonics_directivities.py +++ b/tests/directivities/test_harmonics_directivities.py @@ -25,6 +25,7 @@ def _simulate_rir(directivity, max_order=0): room.compute_rir() return room.rir[0][0] + def test_harmonics_directivity(): """ Test the Room Impulse Response (RIR) generated by a set of microphones @@ -57,6 +58,7 @@ def test_harmonics_directivity(): np.allclose(rir_sum, sh, atol=1e-2) + def test_harmonics_rotated_z(): """ Check that the `orientation` attribute of `RealSphericalHarmonicsDirectivity` @@ -78,6 +80,7 @@ def test_harmonics_rotated_z(): np.testing.assert_allclose(rir_rotated, rir_reference, atol=1e-9, rtol=0) + def test_harmonics_rotated_y(): """ Check that the `orientation` attribute of `RealSphericalHarmonicsDirectivity` @@ -99,6 +102,7 @@ def test_harmonics_rotated_y(): np.testing.assert_allclose(rir_rotated, rir_reference, atol=1e-9, rtol=0) + def test_harmonics_rotated_x(): """ Check that the `orientation` attribute of `RealSphericalHarmonicsDirectivity`