Repository: librosa/librosa

Issue:
pyin crashes for low frequencies and narrow fmin/fmax bands
**Describe the bug**

Currnelty, `pyin` cannot handle (I) low frequencies and (II) narrow bands of `fmin` / `fmax`.


**To Reproduce (issue I)**

Testing pyin for low midi keys:

```py
import librosa

def test_pyin(frequency: float):
    sr = 44100
    y = librosa.tone(frequency=frequency, length=sr, sr=sr)
    librosa.pyin(
        y,
        fmin=0.5 * frequency,
        fmax=2.0 * frequency,
        sr=sr,
    )

for midi_key in reversed(range(20)):
    frequency = librosa.midi_to_hz(midi_key)
    print(f"Testing midi key {midi_key}, frequency = {frequency}")
    test_pyin(frequency)
```

This works up to midi key 17 but then errors. Full output:

```
Testing midi key 19, frequency = 24.499714748859326
Testing midi key 18, frequency = 23.12465141947715
Testing midi key 17, frequency = 21.826764464562746
Testing midi key 16, frequency = 20.601722307054366
Traceback (most recent call last):
  File "/tmp/debug.py", line 18, in <module>
    test_pyin(frequency)
  File "/tmp/debug.py", line 7, in test_pyin
    librosa.pyin(
  File "/home/fabian/git/ZenTabs/.venv/venv_zentabs/lib/python3.8/site-packages/librosa/core/pitch.py", line 819, in pyin
    parabolic_shifts = _parabolic_interpolation(yin_frames)
  File "/home/fabian/git/ZenTabs/.venv/venv_zentabs/lib/python3.8/site-packages/librosa/core/pitch.py", line 485, in _parabolic_interpolation
    shiftsi[..., -1] = 0
IndexError: index -1 is out of bounds for axis 1 with size 0
```

**To Reproduce (issue II)**

I also noticed that pyin doesn't like to incorporate too much prior knowledge about a frequency, i.e., making the `fmin` / `fmax` range relatively narrow. In my use case I knew that I'm searching around a certain expected `f0_expected`, so I figured it is wise to help pyin by limiting the search range symmetrically around that frequency. That fails however for all midi keys:

```py
import librosa

def test_pyin(frequency: float):
    sr = 44100
    y = librosa.tone(frequency=frequency, length=sr, sr=sr)
    librosa.pyin(
        y,
        fmin=0.9 * frequency,
        fmax=(1 / 0.9) * frequency,
        sr=sr,
    )

for midi_key in range(128):
    frequency = librosa.midi_to_hz(midi_key)
    print(f"Testing midi key {midi_key}, frequency = {frequency}")
    try:
        test_pyin(frequency)
    except Exception as e:
        print(e)
```

This fails with `index -1 is out of bounds for axis 1 with size 0` for the low keys, but this time even for midi key 0 - 26.

For midi key 27 and higher it fails with `Target size (37) must be at least input size (51)`.


**Expected behavior**

Ideally these parameter ranges should not crash. If that is too tough to support, the limitation could be documented and perhaps combined with raising an explicit "bad parameter" error.

**Software versions***

```
Linux-5.15.0-60-generic-x86_64-with-glibc2.35
Python 3.8.16 (default, Dec  7 2022, 01:12:06) 
[GCC 11.3.0]
NumPy 1.21.6
SciPy 1.9.1
librosa 0.10.0
INSTALLED VERSIONS
------------------
python: 3.8.16 (default, Dec  7 2022, 01:12:06) 
[GCC 11.3.0]

librosa: 0.10.0

audioread: 3.0.0
numpy: 1.21.6
scipy: 1.9.1
sklearn: 1.2.1
joblib: 1.2.0
decorator: 5.1.1
numba: 0.56.4
soundfile: 0.12.1
pooch: v1.7.0
soxr: 0.3.3
typing_extensions: installed, no version number available
lazy_loader: installed, no version number available
msgpack: 1.0.4

numpydoc: None
sphinx: None
sphinx_rtd_theme: None
matplotlib: 3.7.0
sphinx_multiversion: None
sphinx_gallery: None
mir_eval: None
ipython: None
sphinxcontrib.rsvgconverter: None
pytest: 7.2.1
pytest_mpl: None
pytest_cov: None
samplerate: None
resampy: 0.4.2
presets: None
packaging: 23.0
```


pyin crashes for low frequencies and narrow fmin/fmax bands
**Describe the bug**

Currnelty, `pyin` cannot handle (I) low frequencies and (II) narrow bands of `fmin` / `fmax`.


**To Reproduce (issue I)**

Testing pyin for low midi keys:

```py
import librosa

def test_pyin(frequency: float):
    sr = 44100
    y = librosa.tone(frequency=frequency, length=sr, sr=sr)
    librosa.pyin(
        y,
        fmin=0.5 * frequency,
        fmax=2.0 * frequency,
        sr=sr,
    )

for midi_key in reversed(range(20)):
    frequency = librosa.midi_to_hz(midi_key)
    print(f"Testing midi key {midi_key}, frequency = {frequency}")
    test_pyin(frequency)
```

This works up to midi key 17 but then errors. Full output:

```
Testing midi key 19, frequency = 24.499714748859326
Testing midi key 18, frequency = 23.12465141947715
Testing midi key 17, frequency = 21.826764464562746
Testing midi key 16, frequency = 20.601722307054366
Traceback (most recent call last):
  File "/tmp/debug.py", line 18, in <module>
    test_pyin(frequency)
  File "/tmp/debug.py", line 7, in test_pyin
    librosa.pyin(
  File "/home/fabian/git/ZenTabs/.venv/venv_zentabs/lib/python3.8/site-packages/librosa/core/pitch.py", line 819, in pyin
    parabolic_shifts = _parabolic_interpolation(yin_frames)
  File "/home/fabian/git/ZenTabs/.venv/venv_zentabs/lib/python3.8/site-packages/librosa/core/pitch.py", line 485, in _parabolic_interpolation
    shiftsi[..., -1] = 0
IndexError: index -1 is out of bounds for axis 1 with size 0
```

**To Reproduce (issue II)**

I also noticed that pyin doesn't like to incorporate too much prior knowledge about a frequency, i.e., making the `fmin` / `fmax` range relatively narrow. In my use case I knew that I'm searching around a certain expected `f0_expected`, so I figured it is wise to help pyin by limiting the search range symmetrically around that frequency. That fails however for all midi keys:

```py
import librosa

def test_pyin(frequency: float):
    sr = 44100
    y = librosa.tone(frequency=frequency, length=sr, sr=sr)
    librosa.pyin(
        y,
        fmin=0.9 * frequency,
        fmax=(1 / 0.9) * frequency,
        sr=sr,
    )

for midi_key in range(128):
    frequency = librosa.midi_to_hz(midi_key)
    print(f"Testing midi key {midi_key}, frequency = {frequency}")
    try:
        test_pyin(frequency)
    except Exception as e:
        print(e)
```

This fails with `index -1 is out of bounds for axis 1 with size 0` for the low keys, but this time even for midi key 0 - 26.

For midi key 27 and higher it fails with `Target size (37) must be at least input size (51)`.


**Expected behavior**

Ideally these parameter ranges should not crash. If that is too tough to support, the limitation could be documented and perhaps combined with raising an explicit "bad parameter" error.

**Software versions***

```
Linux-5.15.0-60-generic-x86_64-with-glibc2.35
Python 3.8.16 (default, Dec  7 2022, 01:12:06) 
[GCC 11.3.0]
NumPy 1.21.6
SciPy 1.9.1
librosa 0.10.0
INSTALLED VERSIONS
------------------
python: 3.8.16 (default, Dec  7 2022, 01:12:06) 
[GCC 11.3.0]

librosa: 0.10.0

audioread: 3.0.0
numpy: 1.21.6
scipy: 1.9.1
sklearn: 1.2.1
joblib: 1.2.0
decorator: 5.1.1
numba: 0.56.4
soundfile: 0.12.1
pooch: v1.7.0
soxr: 0.3.3
typing_extensions: installed, no version number available
lazy_loader: installed, no version number available
msgpack: 1.0.4

numpydoc: None
sphinx: None
sphinx_rtd_theme: None
matplotlib: 3.7.0
sphinx_multiversion: None
sphinx_gallery: None
mir_eval: None
ipython: None
sphinxcontrib.rsvgconverter: None
pytest: 7.2.1
pytest_mpl: None
pytest_cov: None
samplerate: None
resampy: 0.4.2
presets: None
packaging: 23.0
```




Discussion:
> Ideally these parameter ranges should not crash. If that is too tough to support, the limitation could be documented and perhaps combined with raising an explicit "bad parameter" error.

Yes, this seems reasonable.  I'll have to look into the details here, but my expectation is that the frequency you're requesting is too low for the specified frame size (default of 2048 at 22050).  Does the error go away if you increase the frame length?

Aside: this will also affect yin. 
>  Does the error go away if you increase the frame length?

Yes, it helps with issue I, i.e., it allows to go lower depending on the frame size. I don't think that it affects the issue with the narrow fmin/fmax band though.

(Note that I initially made a mistake and forgot to pass in `sr` into pyin, but it basically just shifts the point where the error occur. I've updated the post to hopefully contain the right numbers now...)
Ok, thanks for confirming.

The key lines here are https://github.com/librosa/librosa/blob/c800e74f6a6ec5c27e0fa978d7355943cce04359/librosa/core/pitch.py#L809-L811


For your configuration, we have `sr=44100`, `fmax=2*20.601722307054366`, `fmin=0.5 * 20.601722307054366`, and with a default frame length of `2048` we have `win_length = 1024`.

The resulting calculations are:

```python
In [1]: import librosa

In [2]: import numpy as np

In [3]: sr = 44100

In [4]: frame_length = 2048

In [5]: win_length = 1024

In [6]: fmax = 2 * 20.601722307054366

In [7]: fmin = 0.5 * 20.601722307054366

In [8]: np.floor(sr / fmax)
Out[8]: 1070.0

In [9]: np.ceil(sr / fmin), frame_length - win_length - 1
Out[9]: (4282.0, 1023)
```
So when the max period (min frequency) is calculated, it will want no more than 1023 samples.  The min period (max frequency) needs at least 1070 samples.
These values are later used as a slice on the frame array:
https://github.com/librosa/librosa/blob/c800e74f6a6ec5c27e0fa978d7355943cce04359/librosa/core/pitch.py#L414
which results in `1070:1023+1`, which is empty.

I think the thing to do here is detect the empty slice up front and throw an exception.  Probably this should be done as a factored-out compatibility check, as both period calculations involve a branch point (`max(..., 1)` for max_period, `min(sr/fmin, frame - win - 1)` for min_period).  Raising an informative error message will ultimately depend on which branching paths are taken.

I believe this is all correct and intended behavior (inherited from yin and pyin), but the erro r reporting could be improved so I'm flagging as enhancement.
> Ideally these parameter ranges should not crash. If that is too tough to support, the limitation could be documented and perhaps combined with raising an explicit "bad parameter" error.

Yes, this seems reasonable.  I'll have to look into the details here, but my expectation is that the frequency you're requesting is too low for the specified frame size (default of 2048 at 22050).  Does the error go away if you increase the frame length?

Aside: this will also affect yin. 
>  Does the error go away if you increase the frame length?

Yes, it helps with issue I, i.e., it allows to go lower depending on the frame size. I don't think that it affects the issue with the narrow fmin/fmax band though.

(Note that I initially made a mistake and forgot to pass in `sr` into pyin, but it basically just shifts the point where the error occur. I've updated the post to hopefully contain the right numbers now...)
Ok, thanks for confirming.

The key lines here are https://github.com/librosa/librosa/blob/c800e74f6a6ec5c27e0fa978d7355943cce04359/librosa/core/pitch.py#L809-L811


For your configuration, we have `sr=44100`, `fmax=2*20.601722307054366`, `fmin=0.5 * 20.601722307054366`, and with a default frame length of `2048` we have `win_length = 1024`.

The resulting calculations are:

```python
In [1]: import librosa

In [2]: import numpy as np

In [3]: sr = 44100

In [4]: frame_length = 2048

In [5]: win_length = 1024

In [6]: fmax = 2 * 20.601722307054366

In [7]: fmin = 0.5 * 20.601722307054366

In [8]: np.floor(sr / fmax)
Out[8]: 1070.0

In [9]: np.ceil(sr / fmin), frame_length - win_length - 1
Out[9]: (4282.0, 1023)
```
So when the max period (min frequency) is calculated, it will want no more than 1023 samples.  The min period (max frequency) needs at least 1070 samples.
These values are later used as a slice on the frame array:
https://github.com/librosa/librosa/blob/c800e74f6a6ec5c27e0fa978d7355943cce04359/librosa/core/pitch.py#L414
which results in `1070:1023+1`, which is empty.

I think the thing to do here is detect the empty slice up front and throw an exception.  Probably this should be done as a factored-out compatibility check, as both period calculations involve a branch point (`max(..., 1)` for max_period, `min(sr/fmin, frame - win - 1)` for min_period).  Raising an informative error message will ultimately depend on which branching paths are taken.

I believe this is all correct and intended behavior (inherited from yin and pyin), but the erro r reporting could be improved so I'm flagging as enhancement.

Resolving patch:
diff --git a/librosa/core/pitch.py b/librosa/core/pitch.py
--- a/librosa/core/pitch.py
+++ b/librosa/core/pitch.py
@@ -522,7 +522,7 @@ def yin(
         minimum frequency in Hertz.
         The recommended minimum is ``librosa.note_to_hz('C2')`` (~65 Hz)
         though lower values may be feasible.
-    fmax : number > 0 [scalar]
+    fmax : number > fmin, <= sr/2 [scalar]
         maximum frequency in Hertz.
         The recommended maximum is ``librosa.note_to_hz('C7')`` (~2093 Hz)
         though higher values may be feasible.
@@ -582,10 +582,9 @@ def yin(
     if win_length is None:
         win_length = frame_length // 2
 
-    if win_length >= frame_length:
-        raise ParameterError(
-            f"win_length={win_length} cannot exceed given frame_length={frame_length}"
-        )
+    __check_yin_params(
+        sr=sr, fmax=fmax, fmin=fmin, frame_length=frame_length, win_length=win_length
+    )
 
     # Set the default hop if it is not already specified.
     if hop_length is None:
@@ -604,7 +603,7 @@ def yin(
     y_frames = util.frame(y, frame_length=frame_length, hop_length=hop_length)
 
     # Calculate minimum and maximum periods
-    min_period = max(int(np.floor(sr / fmax)), 1)
+    min_period = int(np.floor(sr / fmax))
     max_period = min(int(np.ceil(sr / fmin)), frame_length - win_length - 1)
 
     # Calculate cumulative mean normalized difference function.
@@ -693,7 +692,7 @@ def pyin(
         minimum frequency in Hertz.
         The recommended minimum is ``librosa.note_to_hz('C2')`` (~65 Hz)
         though lower values may be feasible.
-    fmax : number > 0 [scalar]
+    fmax : number > fmin, <= sr/2 [scalar]
         maximum frequency in Hertz.
         The recommended maximum is ``librosa.note_to_hz('C7')`` (~2093 Hz)
         though higher values may be feasible.
@@ -785,10 +784,9 @@ def pyin(
     if win_length is None:
         win_length = frame_length // 2
 
-    if win_length >= frame_length:
-        raise ParameterError(
-            f"win_length={win_length} cannot exceed given frame_length={frame_length}"
-        )
+    __check_yin_params(
+        sr=sr, fmax=fmax, fmin=fmin, frame_length=frame_length, win_length=win_length
+    )
 
     # Set the default hop if it is not already specified.
     if hop_length is None:
@@ -807,7 +805,7 @@ def pyin(
     y_frames = util.frame(y, frame_length=frame_length, hop_length=hop_length)
 
     # Calculate minimum and maximum periods
-    min_period = max(int(np.floor(sr / fmax)), 1)
+    min_period = int(np.floor(sr / fmax))
     max_period = min(int(np.ceil(sr / fmin)), frame_length - win_length - 1)
 
     # Calculate cumulative mean normalized difference function.
@@ -950,3 +948,34 @@ def __pyin_helper(
     observation_probs[n_pitch_bins:, :] = (1 - voiced_prob) / n_pitch_bins
 
     return observation_probs[np.newaxis], voiced_prob
+
+
+def __check_yin_params(
+    *, sr: float, fmax: float, fmin: float, frame_length: int, win_length: int
+):
+    """Check the feasibility of yin/pyin parameters against
+    the following conditions:
+
+    1. 0 < fmin < fmax <= sr/2
+    2. frame_length - win_length - 1 > sr/fmax
+    """
+
+    if fmax > sr / 2:
+        raise ParameterError(f"fmax={fmax:.3f} cannot exceed Nyquist frequency {sr/2}")
+    if fmin >= fmax:
+        raise ParameterError(f"fmin={fmin:.3f} must be less than fmax={fmax:.3f}")
+    if fmin <= 0:
+        raise ParameterError(f"fmin={fmin:.3f} must be strictly positive")
+
+    if win_length >= frame_length:
+        raise ParameterError(
+            f"win_length={win_length} must be less than frame_length={frame_length}"
+        )
+
+    if frame_length - win_length - 1 <= sr // fmax:
+        fmax_feasible = sr / (frame_length - win_length - 1)
+        frame_length_feasible = int(np.ceil(sr/fmax) + win_length + 1)
+        raise ParameterError(
+            f"fmax={fmax:.3f} is too small for frame_length={frame_length}, win_length={win_length}, and sr={sr}. "
+            f"Either increase to fmax={fmax_feasible:.3f} or frame_length={frame_length_feasible}"
+        )


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1183,10 +1183,14 @@ def test_yin_chirp():
 @pytest.mark.parametrize(
     "fmin,fmax,win_length,frame_length",
     [
-        (None, None, None, 2048),
-        (110, None, None, 2048),
-        (None, 880, None, 2048),
-        (110, 880, 2049, 2048),
+        (None, None, None, 2048),  # neither
+        (110, None, None, 2048),  # no fmax
+        (None, 880, None, 2048),  # no fmin
+        (110, 880, 2049, 2048),  # win_length >= frame_length
+        (-1, 440, None, 2048),  # Negative fmin
+        (440, 220, None, 2048),  # fmin > fmax
+        (440, 16000, None, 2048),  # fmax > nyquist
+        (10, 21, None, 2048),  # frame_length - win_length - 1 <= sr/fmax
     ],
 )
 def test_yin_fail(fmin, fmax, win_length, frame_length):