diff --git a/spharpy/__init__.py b/spharpy/__init__.py index 7deefe47..e0fcf0c3 100644 --- a/spharpy/__init__.py +++ b/spharpy/__init__.py @@ -9,6 +9,8 @@ from .classes.sh import SphericalHarmonics from .classes.coordinates import SamplingSphere from .classes.audio import SphericalHarmonicSignal +from .classes.audio import SphericalHarmonicTimeData +from .classes.audio import SphericalHarmonicFrequencyData from . import spherical from . import samplings from . import plot @@ -22,6 +24,8 @@ __all__ = [ 'SphericalHarmonics', 'SphericalHarmonicSignal', + 'SphericalHarmonicTimeData', + 'SphericalHarmonicFrequencyData', 'spherical', 'samplings', 'plot', diff --git a/spharpy/classes/__init__.py b/spharpy/classes/__init__.py index 8c4cb4cd..fbde1875 100644 --- a/spharpy/classes/__init__.py +++ b/spharpy/classes/__init__.py @@ -7,6 +7,8 @@ from .audio import ( SphericalHarmonicSignal, + SphericalHarmonicTimeData, + SphericalHarmonicFrequencyData ) from .coordinates import ( @@ -17,5 +19,7 @@ 'SphericalHarmonicDefinition', 'SphericalHarmonics', 'SphericalHarmonicSignal', + 'SphericalHarmonicTimeData', + 'SphericalHarmonicFrequencyData', 'SamplingSphere', ] diff --git a/spharpy/classes/audio.py b/spharpy/classes/audio.py index 914df3f0..338b6450 100644 --- a/spharpy/classes/audio.py +++ b/spharpy/classes/audio.py @@ -1,12 +1,209 @@ -"""Implementations of audio data container classes.""" - -from pyfar import Signal +from pyfar import Signal, TimeData, FrequencyData +from pyfar.classes.audio import _Audio from spharpy.spherical import renormalize, change_channel_convention +from spharpy.classes import SphericalHarmonicDefinition import numpy as np -class SphericalHarmonicSignal(Signal): - """Create audio object with spherical harmonics coefficients in time or +class _SphericalHarmonicAudio(_Audio, SphericalHarmonicDefinition): + """ + Base class for spherical harmonics audio objects. + + This class extends the pyfar Audio class with all methods and + properties required for spherical harmonics data and are common to the + three sub-classes :py:func:`SphericalHarmonicsTimeData`, + :py:func:`SphericalHarmonicsFrequencyData`, and + :py:func:`SphericalHarmonicsSignal`. + + Objects of this class contain spherical harmonics coefficients which are + directly convertible between channel conventions ACN and FUMA, as + well as the normalizations N3D, SN3D, or MaxN, see [#]_. The definition of + the spherical harmonics basis functions is based on the scipy convention + which includes the Condon-Shortley phase, [#]_, [#]_. + + + Parameters + ---------- + basis_type : str + Type of spherical harmonic basis, either ``'complex'`` or + ``'real'``. + normalization : str + Normalization convention, either ``'N3D'``, ``'NM'``, + ``'maxN'``, ``'SN3D'`` or ``'SNM'``. (maxN is only supported up + to 3rd order) + channel_convention : str + Channel ordering convention, either ``'ACN'`` or ``'FuMa'``. + (FuMa is only supported up to 3rd order) + condon_shortley : bool + Flag to indicate if the Condon-Shortley phase term is included + (``True``) or not (``False``). + domain : ``'time'``, ``'freq'``, optional + Domain of data. The default is ``'time'`` + comment : str + A comment related to `data`. The default is ``None``. + + References + ---------- + .. [#] F. Zotter, M. Frank, "Ambisonics A Practical 3D Audio Theory + for Recording, Studio Production, Sound Reinforcement, and + Virtual Reality", (2019), Springer-Verlag + .. [#] B. Rafely, "Fundamentals of Spherical Array Processing", (2015), + Springer-Verlag + .. [#] E.G. Williams, "Fourier Acoustics", (1999), Academic Press + + """ + def __init__(self, basis_type, normalization, channel_convention, + condon_shortley): + + # check dimensions + if len(self._data.shape) < 3: + raise ValueError("Invalid number of dimensions. Data should have " + "at least 3 dimensions.") + + # calculate n_max + n_max = np.sqrt(self._data.shape[-2])-1 + if n_max - int(n_max) != 0: + raise ValueError("Invalid number of SH channels: " + f"{self._data.shape[-2]}. It must match " + "(n_max + 1)^2.") + n_max = int(n_max) + + # helpers for setting normalization and channel convention + self._current_normalization = None + self._current_channel_convention = None + + SphericalHarmonicDefinition.__init__( + self, + n_max, + basis_type, + channel_convention, + normalization, + condon_shortley, + ) + + def _on_property_change(self): + # check if normalization has changed and recompute accordingly + if (self._current_normalization is not None and + self._current_normalization != self.normalization): + self._data = renormalize( + self._data, + self.channel_convention, + self._current_normalization, + self.normalization, + axis=-2) + self._current_normalization = self.normalization + + # check if channel convention has changed and recompute accordingly + if (self._current_channel_convention is not None and + self._current_channel_convention != self.channel_convention): + self._data = change_channel_convention( + self._data, + self._current_channel_convention, + self.channel_convention, + axis=-2) + self._current_channel_convention = self.channel_convention + + +class SphericalHarmonicTimeData(_SphericalHarmonicAudio, TimeData): + """ + Create spherical harmonic audio object with time domain spherical + harmonic coefficients and times. + + Objects of this class contain time data which is not directly convertible + to the frequency domain, i.e., non-equidistant samples. + + Parameters + ---------- + data : array, double + Raw data in the time domain. The data should have at least 3 + dimensions, according to the 'C' memory layout, e.g. data of + ``shape = (1, 4, 1024)`` has 1 channel with 4 spherical harmonic + coefficients with 1024 samples each. The data can be ``int``, + ``float`` or ``complex``. Data of type ``int`` is converted to + ``float``. + times : array, double + Times in seconds at which the data is sampled. The number of times + must match the size of the last dimension of `data`, i.e., ``data.shape[-1]``. + basis_type : str + Type of spherical harmonic basis, either ``'complex'`` or + ``'real'``. + normalization : str + Normalization convention, either ``'N3D'``, ``'NM'``, + ``'maxN'``, ``'SN3D'`` or ``'SNM'``. (maxN is only supported up + to 3rd order) + channel_convention : str + Channel ordering convention, either ``'ACN'`` or ``'FuMa'``. + (FuMa is only supported up to 3rd order) + condon_shortley : bool + Flag to indicate if the Condon-Shortley phase term is included + (``True``) or not (``False``). + comment : str + A comment related to `data`. The default is ``None``. + is_complex : bool, optional + A flag which indicates if the time data are real or complex-valued. + The default is ``False``. + """ + def __init__(self, data, times, basis_type, normalization, + channel_convention, condon_shortley, comment="", + is_complex=False): + + TimeData.__init__(self, data=data, times=times, comment=comment, + is_complex=is_complex) + _SphericalHarmonicAudio.__init__( + self, basis_type, normalization, channel_convention, + condon_shortley) + + +class SphericalHarmonicFrequencyData(_SphericalHarmonicAudio, FrequencyData): + """ + Create spherical harmonic audio object with frequency domain spherical + harmonic coefficients and frequencies. + + Objects of this class contain frequency data which is not directly + convertible to the time domain, i.e., non-equidistantly spaced bins or + incomplete spectra. + + Parameters + ---------- + data : array, double + Raw data in the frequency domain. The data should have at least + 3 dimensions, according to the 'C' memory layout, e.g. data of + ``shape = (1, 4, 1024)`` has 1 channel with 4 spherical harmonic + coefficients with 1024 frequency bins each.. Data can be ``int``, + ``float`` or ``complex``. Data of type ``int`` is converted to + ``float``. + frequencies : array, double + Frequencies of the data in Hz. The number of frequencies must match + the size of the last dimension of data. + basis_type : str + Type of spherical harmonic basis, either ``'complex'`` or + ``'real'``. + normalization : str + Normalization convention, either ``'N3D'``, ``'NM'``, + ``'maxN'``, ``'SN3D'`` or ``'SNM'``. (maxN is only supported up + to 3rd order) + channel_convention : str + Channel ordering convention, either ``'acn'`` or ``'fuma'``. + (FuMa is only supported up to 3rd order) + condon_shortley : bool + Flag to indicate if the Condon-Shortley phase term is included + (``True``) or not (``False``). + comment : str + A comment related to `data`. The default is ``None``. + """ + + def __init__(self, data, frequencies, basis_type, normalization, + channel_convention, condon_shortley, comment=""): + FrequencyData.__init__(self, data=data, frequencies=frequencies, + comment=comment) + _SphericalHarmonicAudio.__init__( + self, basis_type, normalization, channel_convention, + condon_shortley) + + +class SphericalHarmonicSignal(_SphericalHarmonicAudio, Signal): + """ + Create audio object with spherical harmonics coefficients in time or frequency domain. Objects of this class contain spherical harmonics coefficients which are @@ -36,8 +233,8 @@ class SphericalHarmonicSignal(Signal): ``'real'``. normalization : str Normalization convention, either ``'N3D'``, ``'NM'``, - ``'maxN'``, ``'SN3D'``, or ``'SNM'``. - (maxN is only supported up to 3rd order) + ``'maxN'``, ``'SN3D'`` or ``'SNM'``. (maxN is only supported up + to 3rd order) channel_convention : str Channel ordering convention, either ``'ACN'`` or ``'FuMa'``. (FuMa is only supported up to 3rd order) @@ -73,113 +270,22 @@ class SphericalHarmonicSignal(Signal): .. [#] E.G. Williams, "Fourier Acoustics", (1999), Academic Press """ + def __init__(self, + data, + sampling_rate, + basis_type, + normalization, + channel_convention, + condon_shortley, + n_samples=None, + domain='time', + fft_norm='none', + comment="", + is_complex=False): - def __init__( - self, - data, - sampling_rate, - basis_type, - normalization, - channel_convention, - condon_shortley, - n_samples=None, - domain='time', - fft_norm='none', - comment="", - is_complex=False): - """ - Create SphericalHarmonicSignal with data, and sampling rate. - """ - # check dimensions - if len(data.shape) < 3: - raise ValueError("Invalid number of dimensions. Data should have " - "at least 3 dimensions.") - - # set n_max - n_max = np.sqrt(data.shape[-2])-1 - if n_max - int(n_max) != 0: - raise ValueError("Invalid number of SH channels: " - f"{data.shape[-2]}. It must match (n_max + 1)^2.") - self._n_max = int(n_max) - - # set basis_type - if basis_type not in ["complex", "real"]: - raise ValueError("Invalid basis type, only " - "'complex' and 'real' are supported") - self._basis_type = basis_type - - # set normalization - if normalization not in ["N3D", "NM", "maxN", "SN3D", "SNM"]: - raise ValueError("Invalid normalization, has to be 'N3D', 'NM', " - "'maxN', 'SN3D', or 'SNM', " - f"but is {normalization}") - self._normalization = normalization - - # set channel_convention - if channel_convention not in ["ACN", "FuMa"]: - raise ValueError("Invalid channel convention, has to be 'ACN' " - f"or 'FuMa', but is {channel_convention}") - self._channel_convention = channel_convention - - # set Condon Shortley - if not isinstance(condon_shortley, bool): - raise ValueError("Condon_shortley has to be a bool.") - self._condon_shortley = condon_shortley - - Signal.__init__(self, data, sampling_rate=sampling_rate, + Signal.__init__(self, data=data, sampling_rate=sampling_rate, n_samples=n_samples, domain=domain, fft_norm=fft_norm, comment=comment, is_complex=is_complex) - - @property - def n_max(self): - """Get the maximum spherical harmonic order.""" - return self._n_max - - @property - def basis_type(self): - """Get the type of the spherical harmonic basis.""" - return self._basis_type - - @property - def normalization(self): - """ - Get or set and apply the normalization of the spherical harmonic - coefficients. - """ - return self._normalization - - @normalization.setter - def normalization(self, value): - """ - Get or set and apply the normalization of the spherical harmonic - coefficients. - """ - if self.normalization is not value: - self._data = renormalize(self._data, self.channel_convention, - self.normalization, value, axis=-2) - self._normalization = value - - @property - def condon_shortley(self): - """Get info whether to include the Condon-Shortley phase term.""" - return self._condon_shortley - - @property - def channel_convention(self): - """ - Get or set and apply the channel convention of the spherical harmonic - coefficients. - """ - return self._channel_convention - - @channel_convention.setter - def channel_convention(self, value): - """ - Get or set and apply the channel convention of the spherical harmonic - coefficients. - """ - if self.channel_convention is not value: - self._data = change_channel_convention(self._data, - self.channel_convention, - value, axis=-2) - self._channel_convention = value + _SphericalHarmonicAudio.__init__( + self, basis_type, normalization, channel_convention, + condon_shortley) diff --git a/tests/test_spherical_harmonics_audio.py b/tests/test_spherical_harmonics_audio.py new file mode 100644 index 00000000..97cd3caf --- /dev/null +++ b/tests/test_spherical_harmonics_audio.py @@ -0,0 +1,25 @@ +from pytest import raises +from spharpy.classes.audio import _SphericalHarmonicAudio +from spharpy.classes.audio import SphericalHarmonicTimeData +from spharpy.classes.audio import SphericalHarmonicFrequencyData +import numpy as np + + +def test_init_sh_time_data(): + data = np.ones((1, 4, 4)) + times = [1, 2, 3, 4] + sh_time_data = SphericalHarmonicTimeData( + data, times, basis_type='real', normalization='SN3D', + channel_convention="ACN", condon_shortley=False, + comment="") + assert type(sh_time_data) == SphericalHarmonicTimeData + + +def test_init_sh_frequency_data(): + data = np.ones((1, 4, 4)) + frequencies = [1, 2, 3, 4] + sh_freq_data = SphericalHarmonicFrequencyData( + data, frequencies, basis_type='real', normalization='SN3D', + channel_convention="ACN", condon_shortley=False, + comment="") + assert type(sh_freq_data) == SphericalHarmonicFrequencyData \ No newline at end of file diff --git a/tests/test_spherical_harmonics_signal.py b/tests/test_spherical_harmonics_signal.py index 269deb23..2286e300 100644 --- a/tests/test_spherical_harmonics_signal.py +++ b/tests/test_spherical_harmonics_signal.py @@ -142,9 +142,8 @@ def test_init_wrong_normalization(): [1., 2., 3.]]).reshape(1, 4, 3) with pytest.raises(ValueError, - match="Invalid normalization, has to be 'N3D', 'NM', " - "'maxN', 'SN3D', or 'SNM', but is " - "invalid_normalization"): + match="Invalid normalization, currently only 'N3D', " + "'NM', 'maxN', 'SN3D', 'SNM' are supported"): SphericalHarmonicSignal(data, 44100, basis_type='real', channel_convention='ACN', @@ -184,9 +183,10 @@ def test_init_wrong_channel_convention(): [1., 2., 3.], [1., 2., 3.]]).reshape(1, 4, 3) - with pytest.raises(ValueError, - match="Invalid channel convention, has to be 'ACN' " - "or 'FuMa', but is invalid_convention"): + with pytest.raises( + ValueError, + match="Invalid channel convention, currently only 'ACN' " + "and 'FuMa' are supported"): SphericalHarmonicSignal(data, 44100, basis_type='real', channel_convention='invalid_convention',