Repository: librosa/librosa

Issue:
Drop support for threshold=None in zero_crossings
**Is your feature request related to a problem? Please describe.**
This came up in implementing #1632 - the threshold parameter in zero_crossings is currently optional, but it doesn't need to be.  If threshold is None, it is converted to 0 automatically.


**Describe the solution you'd like**
Threshold=0 is equivalent behavior, and dropping the None support would simplify some type annotations and a bit of the implementation.

Drop support for threshold=None in zero_crossings
**Is your feature request related to a problem? Please describe.**
This came up in implementing #1632 - the threshold parameter in zero_crossings is currently optional, but it doesn't need to be.  If threshold is None, it is converted to 0 automatically.


**Describe the solution you'd like**
Threshold=0 is equivalent behavior, and dropping the None support would simplify some type annotations and a bit of the implementation.



Discussion:
Looking into this one, I think we might to better to rewrite this function from scratch.  The current implementation has quite a bit of overhead due to allocation, slicing, and padding, and I don't think we actually need any of it.  The vectorize + stencil pattern we've been using for other local operations (localmin/max, parabolic interpolation, etc) ought to do the trick here.
Got a stencil implementation going.  Speedup is about 10x, and all redundant copies and paddings have been removed.  PR forthcoming.

![image](https://user-images.githubusercontent.com/1190540/211168307-bdee50e5-2f31-4188-9b67-4b2f59749487.png)

Looking into this one, I think we might to better to rewrite this function from scratch.  The current implementation has quite a bit of overhead due to allocation, slicing, and padding, and I don't think we actually need any of it.  The vectorize + stencil pattern we've been using for other local operations (localmin/max, parabolic interpolation, etc) ought to do the trick here.
Got a stencil implementation going.  Speedup is about 10x, and all redundant copies and paddings have been removed.  PR forthcoming.

![image](https://user-images.githubusercontent.com/1190540/211168307-bdee50e5-2f31-4188-9b67-4b2f59749487.png)


Resolving patch:
diff --git a/librosa/core/audio.py b/librosa/core/audio.py
--- a/librosa/core/audio.py
+++ b/librosa/core/audio.py
@@ -14,7 +14,7 @@
 import soxr
 import lazy_loader as lazy
 
-from numba import jit
+from numba import jit, stencil, guvectorize
 from .fft import get_fftlib
 from .convert import frames_to_samples, time_to_samples
 from .._cache import cache
@@ -1141,11 +1141,47 @@ def __lpc(
     return ar_coeffs
 
 
+@stencil  # type: ignore
+def _zc_stencil(x: np.ndarray, threshold: float, zero_pos: bool) -> np.ndarray:
+    """Stencil to compute zero crossings"""
+    x0 = x[0]
+    if -threshold <= x0 <= threshold:
+        x0 = 0
+
+    x1 = x[-1]
+    if -threshold <= x1 <= threshold:
+        x1 = 0
+
+    if zero_pos:
+        return np.signbit(x0) != np.signbit(x1)  # type: ignore
+    else:
+        return np.sign(x0) != np.sign(x1)  # type: ignore
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, bool_, bool_[:])",
+        "void(float64[:], float64, bool_, bool_[:])",
+    ],
+    "(n),(),()->(n)",
+    cache=True,
+    nopython=True,
+)  # type: ignore
+def _zc_wrapper(
+    x: np.ndarray,
+    threshold: float,
+    zero_pos: bool,
+    y: np.ndarray,
+) -> None:  # pragma: no cover
+    """Vectorized wrapper for zero crossing stencil"""
+    y[:] = _zc_stencil(x, threshold, zero_pos)
+
+
 @cache(level=20)
 def zero_crossings(
     y: np.ndarray,
     *,
-    threshold: Optional[float] = 1e-10,
+    threshold: float = 1e-10,
     ref_magnitude: Optional[Union[float, Callable]] = None,
     pad: bool = True,
     zero_pos: bool = True,
@@ -1162,8 +1198,8 @@ def zero_crossings(
     y : np.ndarray
         The input array
 
-    threshold : float > 0 or None
-        If specified, values where ``-threshold <= y <= threshold`` are
+    threshold : float >= 0
+        If non-zero, values where ``-threshold <= y <= threshold`` are
         clipped to 0.
 
     ref_magnitude : float > 0 or callable
@@ -1237,45 +1273,21 @@ def zero_crossings(
     (array([ 0,  3,  5,  8, 10, 12, 15, 17, 19]),)
     """
 
-    # TODO: drop support for None thresholds, just use 0
-    # Clip within the threshold
-    if threshold is None:
-        threshold = 0.0
-
     if callable(ref_magnitude):
         threshold = threshold * ref_magnitude(np.abs(y))
 
     elif ref_magnitude is not None:
         threshold = threshold * ref_magnitude
 
-    assert threshold is not None  # because mypy can't infer we're float now
-    if threshold > 0:
-        y = y.copy()
-        y[np.abs(y) <= threshold] = 0
-
-    # Extract the sign bit
-    if zero_pos:
-        y_sign = np.signbit(y)
-    else:
-        y_sign = np.sign(y)
+    yi = y.swapaxes(-1, axis)
+    z = np.empty_like(y, dtype=bool)
+    zi = z.swapaxes(-1, axis)
 
-    # Find the change-points by slicing
-    slice_pre = [slice(None)] * y.ndim
-    slice_pre[axis] = slice(1, None)
+    _zc_wrapper(yi, threshold, zero_pos, zi)
 
-    slice_post = [slice(None)] * y.ndim
-    slice_post[axis] = slice(-1)
+    zi[..., 0] = pad
 
-    # Since we've offset the input by one, pad back onto the front
-    padding = [(0, 0)] * y.ndim
-    padding[axis] = (1, 0)
-
-    return np.pad(
-        (y_sign[tuple(slice_post)] != y_sign[tuple(slice_pre)]),
-        padding,
-        mode="constant",
-        constant_values=pad,
-    )
+    return z
 
 
 def clicks(
diff --git a/librosa/feature/spectral.py b/librosa/feature/spectral.py
--- a/librosa/feature/spectral.py
+++ b/librosa/feature/spectral.py
@@ -1082,7 +1082,7 @@ def zero_crossing_rate(
         This is similar to the padding in `librosa.stft`,
         but uses edge-value copies instead of zero-padding.
     **kwargs : additional keyword arguments to pass to `librosa.zero_crossings`
-    threshold : float > 0 or None
+    threshold : float >= 0
         If specified, values where ``-threshold <= y <= threshold`` are
         clipped to 0.
     ref_magnitude : float > 0 or callable


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1064,7 +1064,7 @@ def test_to_mono_multi(y):
 
 
 @pytest.mark.parametrize("data", [np.random.randn(32)])
-@pytest.mark.parametrize("threshold", [None, 0, 1e-10])
+@pytest.mark.parametrize("threshold", [0, 1e-10])
 @pytest.mark.parametrize("ref_magnitude", [None, 0.1, np.max])
 @pytest.mark.parametrize("pad", [False, True])
 @pytest.mark.parametrize("zp", [False, True])