diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b72f6b66..f9793536 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,9 @@ Bugfix - Fixes issue #380: Caused by the attribute ``cartesian`` of ``GridSphere`` not being set properly when the grid is only initialized with a number of points. +- Fixes issue #355: Makes the MicrophoneArray class more bug-proof and adds + some tests. + `0.8.2`_ - 2024-11-06 --------------------- diff --git a/pyroomacoustics/beamforming.py b/pyroomacoustics/beamforming.py index 3595f0c2..31cd1583 100644 --- a/pyroomacoustics/beamforming.py +++ b/pyroomacoustics/beamforming.py @@ -24,6 +24,8 @@ from __future__ import division +from typing import Sequence + import numpy as np import scipy.linalg as la @@ -342,17 +344,16 @@ class MicrophoneArray(object): """Microphone array class.""" def __init__(self, R, fs, directivity=None): - R = np.array(R) - self.dim = R.shape[0] # are we in 2D or in 3D - self.nmic = R.shape[1] # number of microphones + # The array geometry is stored in a (dim, n_mics) array. + self.R = np.array(R) # array geometry # Check the shape of the passed array - if self.dim != 2 and self.dim != 3: + if self.dim not in (2, 3): dim_mismatch = True else: dim_mismatch = False - if R.ndim != 2 or dim_mismatch: + if self.R.ndim != 2 or dim_mismatch: raise ValueError( "The location of microphones should be described by an array_like " "object with 2 dimensions of shape `(2 or 3, n_mics)` " @@ -360,8 +361,6 @@ def __init__(self, R, fs, directivity=None): "the location of a microphone." ) - self.R = R # array geometry - self.fs = fs # sampling frequency of microphones self.set_directivity(directivity) @@ -369,29 +368,65 @@ def __init__(self, R, fs, directivity=None): self.center = np.mean(R, axis=1, keepdims=True) + @property + def dim(self): + return self.R.shape[0] # are we in 2D or in 3D + + def __len__(self): + return self.R.shape[1] + + @property + def nmic(self): + """The number of microphones of the array.""" + return self.__len__() + + @property + def M(self): + """The number of microphones of the array.""" + return self.__len__() + @property def is_directive(self): return any([d is not None for d in self.directivity]) def set_directivity(self, directivities): """ - This functions sets self.directivity as a list of directivities with `n_mics` entries, - where `n_mics` is the number of microphones + This functions sets self.directivity as a list of directivities with + `n_mics` entries, where `n_mics` is the number of microphones. + Parameters ----------- directivities: - single directivity for all microphones or a list of directivities for each microphone + A single directivity for all microphones or a list of directivities + for each microphone """ - if isinstance(directivities, list): + def _is_correct_type(directivity): + return directivity is None or isinstance(directivity, Directivity) + + if isinstance(directivities, Sequence): # list of directivities specified - assert all(isinstance(x, Directivity) for x in directivities) - assert len(directivities) == self.nmic - self.directivity = directivities + for d in directivities: + if not _is_correct_type(d): + raise TypeError( + "Directivities should be of Directivity type, or None (got " + f"{type(d)})." + ) + if not len(directivities) == self.nmic: + raise ValueError( + "Please provide a single Directivity for all microphones, or one " + f"per microphone. Got {len(directivities)} directivities for " + f"{self.nmic} mics." + ) + self.directivity = list(directivities) else: + if not _is_correct_type(directivities): + raise TypeError( + "Directivities should be of Directivity type, or None (got " + f"{type(directivities)})." + ) # only 1 directivity specified - assert directivities is None or isinstance(directivities, Directivity) self.directivity = [directivities] * self.nmic def record(self, signals, fs): @@ -505,6 +540,7 @@ def append(self, locs): self.directivity += locs.directivity else: self.R = np.concatenate((self.R, locs), axis=1) + self.directivity += [None] * locs.shape[1] # in case there was already some signal recorded, just pad with zeros if self.signals is not None: @@ -518,13 +554,6 @@ def append(self, locs): axis=0, ) - def __len__(self): - return self.R.shape[1] - - @property - def M(self): - return self.__len__() - class Beamformer(MicrophoneArray): """ diff --git a/pyroomacoustics/tests/test_microphone_array.py b/pyroomacoustics/tests/test_microphone_array.py new file mode 100644 index 00000000..b442cd20 --- /dev/null +++ b/pyroomacoustics/tests/test_microphone_array.py @@ -0,0 +1,101 @@ +import numpy as np +import pytest + +import pyroomacoustics as pra + +_FS = 16000 + +mic_dir0 = pra.FigureEight( + orientation=pra.DirectionVector(azimuth=90, colatitude=15, degrees=True) +) +mic_dir1 = pra.FigureEight( + orientation=pra.DirectionVector(azimuth=180, colatitude=15, degrees=True) +) + + +@pytest.mark.parametrize("shape", ((1, 2, 3), (10, 2), (1, 10), (10,))) +def test_microphone_array_invalid_shape(shape): + + locs = np.ones(shape) + with pytest.raises(ValueError): + pra.MicrophoneArray(locs, fs=_FS) + + +@pytest.mark.parametrize( + "directivity, exception_type", + ( + ("omni", TypeError), + (["omni"] * 3, TypeError), + ([mic_dir0, "omni", mic_dir1] * 3, TypeError), + ([mic_dir0, mic_dir1], ValueError), + ), +) +def test_microphone_array_invalid_directivity(directivity, exception_type): + + locs = np.ones((3, 3)) + with pytest.raises(exception_type): + pra.MicrophoneArray(locs, fs=_FS, directivity=directivity) + + +@pytest.mark.parametrize( + "shape, with_dir, same_dir", + ( + ((2, 1), False, False), + ((2, 2), False, False), + ((2, 3), False, False), + ((3, 1), False, False), + ((3, 3), False, False), + ((3, 4), False, False), + ((2, 3), True, False), + ((3, 4), True, False), + ((2, 3), True, True), + ((3, 4), True, True), + ), +) +def test_microphone_array_shape_correct(shape, with_dir, same_dir): + + locs = np.ones(shape) + if with_dir: + if same_dir: + mdir = [mic_dir0] * shape[1] + else: + mdir = [mic_dir0, mic_dir1] + [None] * (shape[1] - 2) + else: + mdir = None + mic_array = pra.MicrophoneArray(locs, fs=_FS, directivity=mdir) + + assert mic_array.dim == shape[0] + assert mic_array.M == shape[1] + assert mic_array.nmic == mic_array.M + assert len(mic_array.directivity) == shape[1] + + +@pytest.mark.parametrize( + "shape1, shape2, with_dir, from_raw_locs", + ( + ((3, 2), (3, 2), False, False), + ((3, 2), (3, 2), False, True), + ((3, 2), (3, 2), False, False), + ((3, 2), (3, 2), False, True), + ((3, 2), (3, 2), True, False), + ((3, 2), (3, 2), True, True), + ((3, 2), (3, 1), False, False), + ((3, 2), (3, 1), False, True), + ), +) +def test_microphone_array_append(shape1, shape2, with_dir, from_raw_locs): + if with_dir: + mdir = [mic_dir0, mic_dir1] + [None] * (shape1[1] - 2) + else: + mdir = None + + mic_array = pra.MicrophoneArray(np.ones(shape1), fs=_FS, directivity=mdir) + + if from_raw_locs: + mic_array.append(np.ones(shape2)) + + else: + mic_array.append(pra.MicrophoneArray(np.ones(shape2), fs=_FS)) + + assert mic_array.nmic == shape1[1] + shape2[1] + assert len(mic_array.directivity) == shape1[1] + shape2[1]