From e858ad9f30c5323e67ce3e421ced4c27cdc36de6 Mon Sep 17 00:00:00 2001 From: Robin Scheibler Date: Wed, 6 Nov 2024 11:10:41 +0900 Subject: [PATCH] Makes the resample backend configurable to make the soxr dependency optional. (#377) --- CHANGELOG.rst | 4 + pyroomacoustics/beamforming.py | 1 + pyroomacoustics/parameters.py | 1 + pyroomacoustics/tests/test_resample.py | 100 +++++++++++++++++++++++++ pyroomacoustics/utilities.py | 85 +++++++++++++++++++-- setup.py | 14 +++- 6 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 pyroomacoustics/tests/test_resample.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8b0efa23..adb63d25 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ adheres to `Semantic Versioning `_. Changed ~~~~~~~ +- Makes the ``pyroomacoustics.utilities.resample`` backend is made configurable + to avoid ``soxr`` dependency. The resample backend is configurable to + ``soxr``, ``samplerate``, if these packages are available, and otherwise + falls back to ``scipy.signal.resample_poly``. - Fixes typo in ``materials.json``: ``ceiling_fibre_abosrber -> ceiling_fibre_absorber``. `0.8.1`_ - 2024-10-30 diff --git a/pyroomacoustics/beamforming.py b/pyroomacoustics/beamforming.py index 42cf2b19..3595f0c2 100644 --- a/pyroomacoustics/beamforming.py +++ b/pyroomacoustics/beamforming.py @@ -418,6 +418,7 @@ def record(self, signals, fs): raise NameError("The signals should be a 2D array.") if fs != self.fs: + self.signals = u.resample(signals, fs, self.fs) try: import samplerate diff --git a/pyroomacoustics/parameters.py b/pyroomacoustics/parameters.py index 978de47f..6e5c1b25 100644 --- a/pyroomacoustics/parameters.py +++ b/pyroomacoustics/parameters.py @@ -82,6 +82,7 @@ def get_num_threads(): "octave_bands_n_fft": 512, "octave_bands_base_freq": 125.0, "octave_bands_keep_dc": False, + "resample_backend": "soxr", } diff --git a/pyroomacoustics/tests/test_resample.py b/pyroomacoustics/tests/test_resample.py new file mode 100644 index 00000000..ef50a900 --- /dev/null +++ b/pyroomacoustics/tests/test_resample.py @@ -0,0 +1,100 @@ +""" +Very basic tests to verify that all resampling backends +can be called and are doing their job. +""" + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import pyroomacoustics as pra + + +@pytest.mark.parametrize( + "fs_in, fs_out, backend", + [ + (240, 160, None), + (240, 160, "soxr"), + (240, 160, "samplerate"), + (240, 160, "scipy"), + ], +) +def test_downsample(fs_in, fs_out, backend): + """Idea use a sine above Nyquist of fs_out. It should disappear.""" + assert fs_in > fs_out + f_sine = fs_out / 2.0 + (fs_in - fs_out) / 2.0 * 0.75 + time = np.arange(fs_in * 10) / fs_in + signal_in = np.sin(2.0 * np.pi * time * f_sine) + signal_in = signal_in * np.hanning(signal_in.shape[0]) + signal_out = pra.resample(signal_in, fs_in, fs_out, backend=backend) + + assert abs(signal_out).max() < 1e-3 + + +@pytest.mark.parametrize( + "fs_in, fs_out, backend", + [ + (160, 240, None), + (160, 240, "soxr"), + (160, 240, "samplerate"), + (160, 240, "scipy"), + ], +) +def test_upsample(fs_in, fs_out, backend): + """Idea use a sine above Nyquist of fs_out. It should disappear.""" + assert fs_in < fs_out + + # make a random signal + signal_in = np.random.randn(10 * fs_in) + signal_in = signal_in * np.hanning(signal_in.shape[0]) + + signal_out = pra.resample(signal_in, fs_in, fs_out, backend=backend) + + # the test relies on upper frequency being empty + f_cut = fs_in / 2.0 + (fs_out - fs_in) / 2.0 * 0.75 + signal_out_filt = pra.highpass(signal_out, fs_out, fc=f_cut) + + assert abs(signal_out_filt).max() < 1e-3 + + +if __name__ == "__main__": + + test_cases = [] + + # Test 1 is the eigenmike impulse response + # Reads the file containing the Eigenmike's directivity measurements + eigenmike = pra.MeasuredDirectivityFile("EM32_Directivity") + fs_tgt = 16000 + fs_file = eigenmike.fs + test_cases.append((fs_file, fs_tgt, eigenmike.impulse_responses[0, 0])) + + # Test 2 is a sine + fs_in = 240 + fs_out = 160 + f_sine = fs_out / 2.0 + (fs_in - fs_out) / 2.0 * 0.95 + time = np.arange(fs_in * 10) / fs_in + signal_in = np.sin(2.0 * np.pi * time * f_sine) + signal_in = signal_in * np.hanning(signal_in.shape[0]) + test_cases.append((fs_in, fs_out, signal_in)) + + # Test 3 is some random noise + np.random.seed(0) + fs_in = 160 + fs_out = 240 + signal_in = np.random.randn(fs_in * 10) + test_cases.append((fs_in, fs_out, signal_in)) + + for fs_in, fs_out, rir_original in test_cases: + time_file = np.arange(rir_original.shape[0]) / fs_file + + rirs = {} + for backend in ["soxr", "samplerate", "scipy"]: + rirs[backend] = pra.resample(rir_original, fs_file, fs_tgt, backend=backend) + + fig, ax = plt.subplots(1, 1) + ax.plot(time_file, rir_original, label="Original") + for idx, (backend, rir) in enumerate(rirs.items()): + time_rir = np.arange(rir.shape[0]) / fs_tgt + ax.plot(time_rir, rir, label=backend, linewidth=(3 - idx)) + ax.legend() + plt.show() diff --git a/pyroomacoustics/utilities.py b/pyroomacoustics/utilities.py index cd5cc3d9..3f6ab51f 100644 --- a/pyroomacoustics/utilities.py +++ b/pyroomacoustics/utilities.py @@ -23,11 +23,12 @@ # not, see . from __future__ import division +import fractions import functools import itertools +import warnings import numpy as np -import soxr from scipy import signal from scipy.io import wavfile @@ -35,6 +36,20 @@ from .parameters import constants, eps from .sync import correlate +try: + import soxr + + _has_soxr = True +except ImportError: + _has_soxr = False + +try: + import samplerate + + _has_samplerate = True +except ImportError: + _has_samplerate = False + def requires_matplotlib(func): @functools.wraps(func) # preserves name, docstrings, and signature of function @@ -821,25 +836,68 @@ def angle_function(s1, v2): return np.vstack((az, co)) -def resample(data, old_fs, new_fs): +def resample(data, old_fs, new_fs, backend=None, *args, **kwargs): """ Resample an ndarray from ``old_fs`` to ``new_fs`` along the last axis. Parameters ---------- data : numpy array - Input data to be resampled. + Input data to be resampled expected in shape (..., num_samples). old_fs : int Original sampling rate. new_fs : int New sampling rate. + backend: str + The resampling backend to use. Options are as follows. + All extra arguments are passed to the backend. + + - `soxr`: The default backend. It is the fastest and most + accurate. It is not installed by default, but can be installed + via `pip install python-soxr`. + - `samplerate`: It is the first fallback backend. It is slower, + but as accurate as `soxr`. It is not installed by default, but can + be installed by `pip install samplerate`. + - `scipy`: It is the fallback when none of the other libraries + are installed. This uses `scipy.signal.resample_poly` and is not as + good as the other backend. This will generate a warning unless + specified explicitely. + + The backend used package-wide is set via the constants, + e.g., `pra.constants.set("resample_backend", "soxr")`. Returns ------- - numpy array + The resampled signal. """ + + if backend is None: + # get the package-wide default backend + backend = constants.get("resample_backend") + + if backend not in ("soxr", "samplerate", "scipy"): + raise ValueError( + "Possible choices for the resampling backend are " + "soxr | samplerate | scippy." + ) + + # select the backend + if backend == "soxr" and not _has_soxr: + backend = "samplerate" + + if backend == "samplerate" and not _has_samplerate: + backend = "scipy" + warnings.warn( + "Neither of the resampling backends `soxr` or `samplerate` are installed. " + "Falling back to scipy.signal.resample_poly. To silence this warning, " + "specify `backend=scipy` explicitely." + ) + + # format the data ndim = data.ndim + # for samplerate and soxr the data needs to be in format + # (num_samples, num_channels) if ndim == 1: data = data[:, None] elif ndim == 2: @@ -848,8 +906,25 @@ def resample(data, old_fs, new_fs): shape = data.shape data = data.reshape(-1, data.shape[-1]).T - resampled_data = soxr.resample(data, old_fs, new_fs) + if backend == "soxr": + resampled_data = soxr.resample(data, old_fs, new_fs, *args, **kwargs) + elif backend == "samplerate": + resampled_data = samplerate.resample( + data, new_fs / old_fs, "sinc_best", *args, **kwargs + ) + else: + # first, simplify the fraction + rate_frac = fractions.Fraction(int(new_fs), int(old_fs)) + resampled_data = signal.resample_poly( + data, + up=rate_frac.numerator, + down=rate_frac.denominator, + axis=0, + *args, + **kwargs + ) + # restore the original shape of the data if ndim == 1: resampled_data = resampled_data[:, 0] elif ndim == 2: diff --git a/setup.py b/setup.py index 5edb9ad2..544a6f08 100644 --- a/setup.py +++ b/setup.py @@ -186,13 +186,23 @@ def build_extensions(self): # Libroom C extension ext_modules=ext_modules, # Necessary to keep the source files - package_data={"pyroomacoustics": ["*.pxd", "*.pyx", "data/materials.json"]}, + package_data={ + "pyroomacoustics": [ + "*.pxd", + "*.pyx", + "data/materials.json", + "data/sofa_files.json", + "data/sofa/AKG_c480_c414_CUBE.sofa", + "data/sofa/EM32_Directivity.sofa", + "data/sofa/mit_kemar_large_pinna.sofa", + "data/sofa/mit_kemar_normal_pinna.sofa", + ] + }, install_requires=[ "Cython", "numpy>=1.13.0", "scipy>=0.18.0", "pybind11>=2.2", - "soxr", ], cmdclass={"build_ext": BuildExt}, # taken from pybind11 example zip_safe=False,