Skip to content

Feature/add sh axis property multichannel - #329

Open
tluebeck wants to merge 36 commits into
developfrom
feature/add_sh_axis_property_multichannel
Open

tluebeck wants to merge 36 commits into
developfrom
feature/add_sh_axis_property_multichannel

Conversation

@tluebeck

@tluebeck tluebeck commented May 18, 2026

Copy link
Copy Markdown
Contributor

Which issue(s) are closed by this pull request?

Closes #326

Changes proposed in this pull request:

  • Introduce property caxis_spherical_harmonics in _SphericalHarmonicsAudio, SphericalHarmonicTimeData , SphericalHarmonicFrequencyData, and SphericalHarmonicSignal which defines the axis along which SHSignals store SH coefficients
  • Adapt rotation accordingly
  • allow caxis_spherical_harmonics property for multichannel SH data

@tluebeck
tluebeck requested a review from mberz May 18, 2026 14:01
@mberz
mberz changed the base branch from develop to feature/add_sh_axis_property May 21, 2026 16:33

@mberz mberz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very much feasible!
I think we'll need an update to the n_max property and additional checks to ensure that the order of each dimensions corresponding to spherical harmonic data matches.
Or would you suggest to allow different orders for each dimension?

Comment thread spharpy/spherical.py Outdated
Comment on lines +288 to +295
# check if all sh_channels match
sh_channels = [data.shape[a] for a in axis]

if len(set(sh_channels)) != 1:
raise ValueError(
"All SH axes must have the same number of channels, "
f"but got {sh_channels}"
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check should not be necessary, since the order governs the total number of channels. So all axes containing spherical harmonic data should have the same length.

So this check should probably be moved to the respective SphericalHarmonic* classes.

Comment thread spharpy/spherical.py Outdated
# check if all sh_channels match
sh_channels = [data.shape[a] for a in axis]

if len(set(sh_channels)) != 1:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as above. Implementing the check also makes individual checks in each function obsolete.

tluebeck added 3 commits May 22, 2026 10:31
move check if all spherical harmonics axes have equal length to data init
test change_channel_convention and renormalize with mutlichannel data
@tluebeck tluebeck added this to the v1.1.0 milestone Sep 9, 2026
@tluebeck
tluebeck marked this pull request as ready for review September 9, 2026 14:35
@tluebeck
tluebeck requested a review from f-brinkmann September 9, 2026 14:47
@tluebeck
tluebeck requested a review from mberz September 9, 2026 15:28
@f-brinkmann
f-brinkmann requested review from a team and ahms5 September 14, 2026 08:49
@f-brinkmann
f-brinkmann force-pushed the feature/add_sh_axis_property branch from d345de4 to a6f6306 Compare September 14, 2026 09:20
@tluebeck
tluebeck changed the base branch from feature/add_sh_axis_property to develop September 15, 2026 07:47

@f-brinkmann f-brinkmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing ths forward. Docs look mainly good but I think I found one bug and have three things for general discussion. I did not yet review the tests.

  1. I think renormalize does not work as intended yet (see detailed comment). If true, tests should be adapted to detect the unwanted behavior and test the desired.
  2. I think it would be more consistent and easier to handle if caxis_spherical_harmonics would always be a tuple.
  3. This is the biggest: We are inheriting from pyfar._Audio which has the reshape, transpose and T methods that move around axes. I think we need to overload them in _SphericalHarmonicAudio to change caxis_spherical_harmonics accordingly. Should be rather straigt forward: call the parent function first, then update caxis_spherical_harmonics. For saftey I would suggest to check dimensions again and would suggest to add a private setter for caxis_spherical_harmonics to _SphericalHarmonicAudio that does the checks. This would also reduce code redundancy, because these checks are implemented three times at the moment (once in each SH Audio class).

Comment thread spharpy/classes/audio.py Outdated
samples and the second to last the spherical harmonic coefficients. If
the raw data have more then 3 dimensions `caxis_spherical_harmonics`
defines the axis holding the spherical harmonic coefficients. The
default is -1 (second to last channel axis). Accordingly, the default

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be last caxis or second to last axis

Suggested change
default is -1 (second to last channel axis). Accordingly, the default
default is -1 (last channel axis). Accordingly, the default

(should be changed everywhere)

Comment thread spharpy/classes/audio.py Outdated
``float`` or ``complex``. Data of type ``int`` is converted to
``float``.
coefficients with 1024 samples each, and
`caxis_spherical_harmonics` = -1. The data can be ``int``, ``float``

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`caxis_spherical_harmonics` = -1. The data can be ``int``, ``float``
a `caxis_spherical_harmonics` of -1. The data can be ``int``, ``float``

or

Suggested change
`caxis_spherical_harmonics` = -1. The data can be ``int``, ``float``
``caxis_spherical_harmonics = -1``. The data can be ``int``, ``float``

(should be changed everywhere)

Comment thread spharpy/classes/audio.py Outdated
Comment on lines +323 to +326
if abs(caxis_spherical_harmonics) > data.ndim:
raise ValueError(
f"caxis_spherical_harmonics ({caxis_spherical_harmonics}) "
f"exceeds the number of dimensions of data ({data.ndim})")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this fails if caxis_spherical_harmonics is a tuple. It can probably be omitted because it is checked below as well.

Comment thread spharpy/classes/audio.py Outdated
Comment on lines +328 to +336
if isinstance(caxis_spherical_harmonics, int):
caxis_spherical_harmonics = (caxis_spherical_harmonics, )

for caxis in caxis_spherical_harmonics:
if abs(caxis) > data.ndim:
raise ValueError(
f"caxis_spherical_harmonics "
f"({caxis}) exceeds the number "
f"of dimensions of data ({data.ndim})")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this code identical for all three SHAudio classes? I would suggest to put it into a private function to make it reusable

Comment thread spharpy/classes/audio.py Outdated
caxis_spherical_harmonics = (caxis_spherical_harmonics, )

for caxis in caxis_spherical_harmonics:
if abs(caxis) > data.ndim:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this fail, if data is an list but not an ndarray? I think this should be moved after data = _atleast_3d_first_dimension(data)

Comment thread spharpy/transforms/rotations.py Outdated
sh_caxis = target.caxis_spherical_harmonics - 1

# move SH axis to front
data = np.moveaxis(data, sh_caxis, 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The numpy docs say that source and destination axes must be unique, so I assume this must be

Suggested change
data = np.moveaxis(data, sh_caxis, 0)
data = np.moveaxis(data, sh_caxis, np.arange(len(sh_caxis)))

for things to work if we a have a tuple of SH caxes

Comment thread spharpy/transforms/rotations.py Outdated
rotated_data = np.tensordot(M, data, axes=(1, 0))

# move SH axis back to original position
rotated_data = np.moveaxis(rotated_data, 0, sh_caxis)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above

Comment thread spharpy/spherical.py
Comment on lines +285 to +286
if isinstance(axis, int):
axis = (axis,)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would not be required anymore if axis was always a tuple.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it for all the private methods, but I think for convenience, it would be nice to allow both, int and tuple here for the public methods.

Comment thread spharpy/spherical.py Outdated
shape = [1] * data.ndim
shape[axis] = data.shape[axis]

shape[axis[0]] = sh_channels

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this does not work as intended yet. I get the same result for renormalizing an array with a single and two SH axes:

import spharpy
import numpy as np

data = np.ones((1, 4, 4, 8))
channel_convention = 'ACN'
current_norm = 'N3D'
target_norm = 'SN3D'

data1 = spharpy.spherical.renormalize(
    data, channel_convention, current_norm, target_norm, (1, ))
data2 = spharpy.spherical.renormalize(
    data, channel_convention, current_norm, target_norm, (1, 2))

np.all(data1 == data2)  # is True but should not be true (?)

Comment thread spharpy/spherical.py
Comment on lines +372 to +373
if isinstance(axis, int):
axis = (axis,)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would not be required anymore of axis was always a tuple.

- bugfix normalization
- edit caxis_spherical_harmonic getter
- refactor _assert_valid_number_of_sh_channels and _convert_to_standard_definition
- update tests
- override transpose
- implement override reshape
- bugfix rotation
@tluebeck

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Good catches. I think everything is adapted and fixed now. Maybe we should discuss on the new behavior of reshaping SHsignals. Do we want to reshape caxis_spherical_harmonics as well? I think that wouldn't make sense.

@f-brinkmann

Copy link
Copy Markdown
Member

In addition to the above, I noticed that _Audio.flatten() might also be difficult because it would interleave SH channels with non SH channels. Running an LLM on all our repos found the following uses on a Signal level:

  • all pyfar.plot functions flatten the signal
  • pyfar/dsp/filter/gammatone.py:391 and pyfar/dsp/interpolation.py:801 and some pyrato.edc methods flatten the input and reshape after processing

All usages are fine since _Audio.flatten() returns a copy of the Signal. Because all functions could be usefull with an SH Signal, how can we make this work?

  • SHAudio.flatten could return an Audio object (not SHAudio)
  • gammatone and interpolation would return an SHAudio object (fine)
  • EDC methods return TimeData and information about the SH axes will be lost (not ideal)

@f-brinkmann f-brinkmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick implementation. Reshaping seems to get really annoying. Maybe something we can discuss in general.

Comment thread spharpy/classes/audio.py

def _assert_valid_number_of_sh_channels(shape, sh_axis):
def _assert_valid_caxis_spherical_harmonics(data, caxis_spherical_harmonics):
"""Check if the spherical harmonic channel axes are ``ìnt`` and do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo should be 'i' not 'ì'

Comment thread spharpy/classes/audio.py
------
ValueError
Raised if the number of spherical harmonic channels does not match
(n_max + 1)^2 for an integer n_max.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is checked below. Can you update this please?

Comment thread spharpy/classes/audio.py

Parameters
----------
data : numpy.ndarray

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to directly pass data.ndim

Comment thread spharpy/classes/audio.py
Comment on lines +294 to +295
Return reshaped copy of the SphericalHarmonicAudio object. Axes
containing the spherical harmonics coefficients can not be reshaped.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did not think of this before to be honest. A different solution would be to allow freely reshaping and return an Audio either always or if SH channels are affected by reshaping.

Comment thread spharpy/classes/audio.py
Comment on lines +321 to +328
new_caxis_sh = []
for caxis in self._caxis_spherical_harmonics:
n_sh_channels = old_cshape[caxis]
if n_sh_channels not in new_cshape:
raise ValueError(
"The requested new shape does not leave a "
"channel axis to hold the spherical "
"harmonic channels.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this always works. Could fail in case the size of a non-SH channel matches that of an SH channel.

Comment thread spharpy/spherical.py
shape = [1] * data.ndim

shape[axis[0]] = sh_channels
# calculate one renormalization factor per sh channel

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required? All SH channels have the same order and hence the same normalization will be applied everywhere.

Comment thread spharpy/spherical.py
shape = [1] * data.ndim
shape[a] = sh_channels

data_renorm *= factor.reshape(shape)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above - the same factor is applied all the time, which is correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants