Repository: librosa/librosa

Issue:
Support ndim>2 in all resamplers
**Is your feature request related to a problem? Please describe.**
Noted in #1556 , our support for resampling of high-dimensional (ndim>2) signals is not exactly complete.  Some converters support this out of the box (resampy, scipy) and others do not (samplerate, soxr).

**Describe the solution you'd like**

There are two options here:

1. Send PRs to the upstream packages to extend their support for high-dimensional signals.
2. Implement vectorization shims on our side so that the upstream packages don't have to change.  This would use `np.vectorize` to automatically convert high-D resamples into a collection of low-D resamples.


**Describe alternatives you've considered**

I'm not sure that option (1) is preferable, as it could complicate our dependency version requirements, and it may not even be in scope for those packages in the first place.

Option 2 could have some complications due to memory layout, dtypes, etc; we should prototype this out to see if it's feasible before committing

**Additional context**

Related issues: #1556 #1558 

Support ndim>2 in all resamplers
**Is your feature request related to a problem? Please describe.**
Noted in #1556 , our support for resampling of high-dimensional (ndim>2) signals is not exactly complete.  Some converters support this out of the box (resampy, scipy) and others do not (samplerate, soxr).

**Describe the solution you'd like**

There are two options here:

1. Send PRs to the upstream packages to extend their support for high-dimensional signals.
2. Implement vectorization shims on our side so that the upstream packages don't have to change.  This would use `np.vectorize` to automatically convert high-D resamples into a collection of low-D resamples.


**Describe alternatives you've considered**

I'm not sure that option (1) is preferable, as it could complicate our dependency version requirements, and it may not even be in scope for those packages in the first place.

Option 2 could have some complications due to memory layout, dtypes, etc; we should prototype this out to see if it's feasible before committing

**Additional context**

Related issues: #1556 #1558 



Discussion:
I think this might actually just be as easy as replacing
https://github.com/librosa/librosa/blob/5f58277c919c6526dc45df5cf56c9af8907aa550/librosa/core/audio.py#L633-L641
by
```python
        import samplerate

        # We have to transpose here to match libsamplerate
        y_hat = np.apply_along_axis(samplerate.resample, axis=-1, arr=y, ratio=ratio, converter_type=res_type)
    elif res_type.startswith("soxr"):
        import soxr

        # We have to transpose here to match soxr
        y_hat = np.apply_along_axis(soxr.resample, axis=-1, arr=y, in_rate=orig_sr, out_rate=target_sr, quality=res_type)
```
with no additional functions or wrappers required.
I think this might actually just be as easy as replacing
https://github.com/librosa/librosa/blob/5f58277c919c6526dc45df5cf56c9af8907aa550/librosa/core/audio.py#L633-L641
by
```python
        import samplerate

        # We have to transpose here to match libsamplerate
        y_hat = np.apply_along_axis(samplerate.resample, axis=-1, arr=y, ratio=ratio, converter_type=res_type)
    elif res_type.startswith("soxr"):
        import soxr

        # We have to transpose here to match soxr
        y_hat = np.apply_along_axis(soxr.resample, axis=-1, arr=y, in_rate=orig_sr, out_rate=target_sr, quality=res_type)
```
with no additional functions or wrappers required.

Resolving patch:
diff --git a/librosa/core/audio.py b/librosa/core/audio.py
--- a/librosa/core/audio.py
+++ b/librosa/core/audio.py
@@ -500,7 +500,7 @@ def to_mono(y):
 
 @cache(level=20)
 def resample(
-    y, *, orig_sr, target_sr, res_type="kaiser_best", fix=True, scale=False, **kwargs
+    y, *, orig_sr, target_sr, res_type="kaiser_best", fix=True, scale=False, axis=-1, **kwargs
 ):
     """Resample a time series from orig_sr to target_sr
 
@@ -510,8 +510,8 @@ def resample(
 
     Parameters
     ----------
-    y : np.ndarray [shape=(..., n)]
-        audio time series.  Multi-channel is supported.
+    y : np.ndarray [shape=(..., n, ...)]
+        audio time series, with `n` samples along the specified axis.
 
     orig_sr : number > 0 [scalar]
         original sampling rate of ``y``
@@ -561,14 +561,17 @@ def resample(
         Scale the resampled signal so that ``y`` and ``y_hat`` have approximately
         equal total energy.
 
+    axis : int
+        The target axis along which to resample.  Defaults to the trailing axis.
+
     **kwargs : additional keyword arguments
         If ``fix==True``, additional keyword arguments to pass to
         `librosa.util.fix_length`.
 
     Returns
     -------
-    y_hat : np.ndarray [shape=(..., n * target_sr / orig_sr)]
-        ``y`` resampled from ``orig_sr`` to ``target_sr``
+    y_hat : np.ndarray [shape=(..., n * target_sr / orig_sr, ...)]
+        ``y`` resampled from ``orig_sr`` to ``target_sr`` along the target axis
 
     Raises
     ------
@@ -606,10 +609,10 @@ def resample(
 
     ratio = float(target_sr) / orig_sr
 
-    n_samples = int(np.ceil(y.shape[-1] * ratio))
+    n_samples = int(np.ceil(y.shape[axis] * ratio))
 
     if res_type in ("scipy", "fft"):
-        y_hat = scipy.signal.resample(y, n_samples, axis=-1)
+        y_hat = scipy.signal.resample(y, n_samples, axis=axis)
     elif res_type == "polyphase":
         if int(orig_sr) != orig_sr or int(target_sr) != target_sr:
             raise ParameterError(
@@ -622,7 +625,7 @@ def resample(
         orig_sr = int(orig_sr)
         target_sr = int(target_sr)
         gcd = np.gcd(orig_sr, target_sr)
-        y_hat = scipy.signal.resample_poly(y, target_sr // gcd, orig_sr // gcd, axis=-1)
+        y_hat = scipy.signal.resample_poly(y, target_sr // gcd, orig_sr // gcd, axis=axis)
     elif res_type in (
         "linear",
         "zero_order_hold",
@@ -632,18 +635,20 @@ def resample(
     ):
         import samplerate
 
-        # We have to transpose here to match libsamplerate
-        y_hat = samplerate.resample(y.T, ratio, converter_type=res_type).T
+        # Use numpy to vectorize the resampler along the target axis
+        # This is because samplerate does not support ndim>2 generally.
+        y_hat = np.apply_along_axis(samplerate.resample, axis=axis, arr=y, ratio=ratio, converter_type=res_type)
     elif res_type.startswith("soxr"):
         import soxr
 
-        # We have to transpose here to match soxr
-        y_hat = soxr.resample(y.T, orig_sr, target_sr, quality=res_type).T
+        # Use numpy to vectorize the resampler along the target axis
+        # This is because soxr does not support ndim>2 generally.
+        y_hat = np.apply_along_axis(soxr.resample, axis=axis, arr=y, in_rate=orig_sr, out_rate=target_sr, quality=res_type)
     else:
-        y_hat = resampy.resample(y, orig_sr, target_sr, filter=res_type, axis=-1)
+        y_hat = resampy.resample(y, orig_sr, target_sr, filter=res_type, axis=axis)
 
     if fix:
-        y_hat = util.fix_length(y_hat, size=n_samples, **kwargs)
+        y_hat = util.fix_length(y_hat, size=n_samples, axis=axis, **kwargs)
 
     if scale:
         y_hat /= np.sqrt(ratio)


Test patch:
diff --git a/tests/test_multichannel.py b/tests/test_multichannel.py
--- a/tests/test_multichannel.py
+++ b/tests/test_multichannel.py
@@ -1008,3 +1008,35 @@ def test_trim_multichannel(y_multi):
     # intersection of the individual intervals
     assert ival[0] == max(ival0[0], ival1[0])
     assert ival[1] == min(ival0[1], ival1[1])
+
+
+@pytest.mark.parametrize('res_type', ('scipy', 'polyphase', 'sinc_fastest', 'kaiser_fast', 'soxr_qq'))
+def test_resample_multichannel(y_multi, res_type):
+    # Test multi-channel resampling with all backends: scipy, samplerate, resampy, soxr
+    y, sr = y_multi
+
+    y_res = librosa.resample(y=y, orig_sr=sr, target_sr=sr//2, res_type=res_type)
+    y0_res = librosa.resample(y=y[0], orig_sr=sr, target_sr=sr//2, res_type=res_type)
+    y1_res = librosa.resample(y=y[1], orig_sr=sr, target_sr=sr//2, res_type=res_type)
+
+    assert np.allclose(y_res[0], y0_res)
+    assert np.allclose(y_res[1], y1_res)
+    assert y_res[0].shape == y0_res.shape
+
+
+@pytest.mark.parametrize('res_type', ('scipy', 'polyphase', 'sinc_fastest', 'kaiser_fast', 'soxr_qq'))
+@pytest.mark.parametrize('x', [np.zeros((2, 2, 2, 22050))])
+def test_resample_highdim(x, res_type):
+    # Just run these to verify that it doesn't crash with ndim>2
+    y = librosa.resample(x, orig_sr=22050, target_sr=11025, res_type=res_type)
+
+
+@pytest.mark.parametrize('res_type', ('scipy', 'polyphase', 'sinc_fastest', 'kaiser_fast', 'soxr_qq'))
+@pytest.mark.parametrize('x, axis', [(np.zeros((2, 2, 2, 22050)), -1), (np.zeros((22050, 2, 3)), 0)])
+def test_resample_highdim_axis(x, axis, res_type):
+    # Resample along the target axis
+    y = librosa.resample(x, orig_sr=22050, target_sr=11025, axis=axis, res_type=res_type)
+
+    # Verify that the target axis is the correct shape
+    assert y.shape[axis] == 11025
+    assert y.ndim == x.ndim