Skip to content

Commit

Permalink
Makes the resample backend configurable to make the soxr dependency o…
Browse files Browse the repository at this point in the history
…ptional. (#377)
  • Loading branch information
fakufaku authored Nov 6, 2024
1 parent 021a666 commit e858ad9
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 7 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ adheres to `Semantic Versioning <http://semver.org/spec/v2.0.0.html>`_.
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
Expand Down
1 change: 1 addition & 0 deletions pyroomacoustics/beamforming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions pyroomacoustics/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
100 changes: 100 additions & 0 deletions pyroomacoustics/tests/test_resample.py
Original file line number Diff line number Diff line change
@@ -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()
85 changes: 80 additions & 5 deletions pyroomacoustics/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,33 @@
# not, see <https://opensource.org/licenses/MIT>.
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

from .doa import cart2spher
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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit e858ad9

Please sign in to comment.