Repository: librosa/librosa

Issue:
VQT wavelet filters with more graceful octave boundaries
**Is your feature request related to a problem? Please describe.**

In https://github.com/librosa/librosa/issues/1073#issuecomment-968752759 , we sorted out how to handle bandwidth calculation for wavelet filters at the boundaries of the frequency range.  This resulted in the following chunk of code:

https://github.com/librosa/librosa/blob/1f770e8fa778a548111204192757043d0313a010/librosa/filters.py#L797-L804

where we estimate the "local bins per octave" in the neighborhood of the center frequency for each filter.  For "interior frequencies" (i.e. not fmin or fmax), we use one above and one below to do this; for the boundary frequencies, we just use one above (fmin) or one below (fmax).

This is fine if the frequencies span the entire range we care about, but we also often generate filters one octave at a time.  In this case, we'll get slightly different results between the full-range and octave-range calculations when the frequency spacing is uneven.


**Describe the solution you'd like**

We could refine this a little by allowing the length calculator to assume octave equivalence above or below (or both) the frequency range.  That is, instead of

```python
bpo[0] = 1 / (logf[1] - logf[0]) 
```
for the BPO estimate at `f[0]`, we could do
```python
bpo[0] = 2 / (logf[1] - (logf[-1] - 1))  # divide fmax/2 to get one freq down from f[0]
```
and likewise for the top frequency:
```python
bpo[0] = 1 / (logf[1] - logf[0]) 
```
becomes
```python
bpo[-1] = 2 / (logf[0] + 1 - logf[-2]) 
```

These behaviors should be controlled by flags to enable/disable octave equivalence on each side.
The result should ensure consistent behavior between full-range and octave-range frequency generation, except at the bottom of the lowest or top of the highest octaves.

Note: when using equal spacing (ie CQT or VQT with intervals='equal'), none of this should matter, and the behavior should be identical in all cases.

**Describe alternatives you've considered**
:shrug: 

**Additional context**
Since JIT is experimental right now, I think we can tweak this behavior in a minor without jumping revisions.


Discussion:
Thinking on this a bit more, I have an alternative solution that might have the same effect without adding complexity to the filter constructor.

Working through the VQT function, we first construct the lengths (at native sampling rate) of the entire filter-bank to check Nyquist and determine if/how much early downsampling can be implemented:
https://github.com/librosa/librosa/blob/1f770e8fa778a548111204192757043d0313a010/librosa/core/constantq.py#L947-L956

In doing so, we pass in an ET-derived α:
https://github.com/librosa/librosa/blob/1f770e8fa778a548111204192757043d0313a010/librosa/core/constantq.py#L1481-L1494

This α is ignored and clobbered when there is more than one frequency:
https://github.com/librosa/librosa/blob/1f770e8fa778a548111204192757043d0313a010/librosa/filters.py#L795-L808

Later on in the octave loop, we construct each octave's filterbanks separately:
https://github.com/librosa/librosa/blob/1f770e8fa778a548111204192757043d0313a010/librosa/core/constantq.py#L985-L1005
and
https://github.com/librosa/librosa/blob/1f770e8fa778a548111204192757043d0313a010/librosa/core/constantq.py#L1044-L1067
That same dummy `alpha` variable is propagated through, and only exists to catch the cases where we end up with a single frequency in some octave.

I'm thinking we can resolve this more elegantly by correctly calculating the `alpha` array up front (we're implicitly doing that anyway) and then providing slices for each octave just like we do with the frequencies.  All we'd really have to do is *not* ignore the provided `alpha` in `filters.wavelet_lengths`, and factor out the calculation to its own helper function.  We already have such a helper function in `core.constantq.__bpo_to_alpha`, it's just not as smart as it should be.

The result of this means that we don't need any special-case octave folding logic in the wavelet constructor.  What do you think @lostanlen ?
To summarize above, here are the proposed changes:

- Rename `constantq.__bpo_to_alpha` to `__et_alpha`, as it's slightly more descriptive of what's going on.
- Factor out the frequency-to-alpha computation from `filters.wavelet_lengths`, call it ... `frequencies_to_alpha` ?  I'm not a huge fan of `alpha` as a function name, but I'm not sure what else to call it... relative bandwidth maybe?
- In CQT module, put in a branch point at all alpha computations.  If n_bins == 1, use the ET alpha.  Otherwise, compute the correct alphas from the frequency set.
- Modify `wavelet_lengths` to use a provided alpha, or call `frequences_to_alpha` (or whatever we name it) if none is given
- Slice alpha array down to the octave for each iteration of vqt

The only user-facing API change here is that `alpha` may no longer be ignored by `wavelet_lengths` if it is provided.  If a user wants this ignoring behavior, they can pass in `alpha=None` (which is the default anyway).  And, of course, the filter bandwidths will be ever-so-slightly different around octave boundaries when using non-uniform spacings.  

These changes both seem acceptable to me for a minor revision.

Resolving patch:
diff --git a/librosa/core/constantq.py b/librosa/core/constantq.py
--- a/librosa/core/constantq.py
+++ b/librosa/core/constantq.py
@@ -309,8 +309,11 @@ def hybrid_cqt(
     # Get all CQT frequencies
     freqs = cqt_frequencies(n_bins, fmin=fmin, bins_per_octave=bins_per_octave)
 
-    # Compute an alpha parameter, just in case we need it
-    alpha = __bpo_to_alpha(bins_per_octave)
+    # Pre-compute alpha
+    if n_bins == 1:
+        alpha = __et_relative_bw(bins_per_octave)
+    else:
+        alpha = filters._relative_bandwidth(freqs=freqs)
 
     # Compute the length of each constant-Q basis function
     lengths, _ = filters.wavelet_lengths(
@@ -486,7 +489,10 @@ def pseudo_cqt(
 
     freqs = cqt_frequencies(fmin=fmin, n_bins=n_bins, bins_per_octave=bins_per_octave)
 
-    alpha = __bpo_to_alpha(bins_per_octave)
+    if n_bins == 1:
+        alpha = __et_relative_bw(bins_per_octave)
+    else:
+        alpha = filters._relative_bandwidth(freqs=freqs)
 
     lengths, _ = filters.wavelet_lengths(
         freqs=freqs, sr=sr, window=window, filter_scale=filter_scale, alpha=alpha
@@ -654,9 +660,11 @@ def icqt(
 
     n_octaves = int(np.ceil(float(n_bins) / bins_per_octave))
 
-    # truncate the cqt to max frames if helpful
     freqs = cqt_frequencies(fmin=fmin, n_bins=n_bins, bins_per_octave=bins_per_octave)
-    alpha = __bpo_to_alpha(bins_per_octave)
+    if n_bins == 1:
+        alpha = __et_relative_bw(bins_per_octave)
+    else:
+        alpha = filters._relative_bandwidth(freqs=freqs)
 
     lengths, f_cutoff = filters.wavelet_lengths(
         freqs=freqs, sr=sr, window=window, filter_scale=filter_scale, alpha=alpha
@@ -702,7 +710,7 @@ def icqt(
             sparsity,
             window=window,
             dtype=dtype,
-            alpha=alpha,
+            alpha=alpha[sl],
         )
 
         # Transpose the basis
@@ -944,7 +952,10 @@ def vqt(
     freqs_top = freqs[-bins_per_octave:]
 
     fmax_t: float = np.max(freqs_top)
-    alpha = __bpo_to_alpha(bins_per_octave)
+    if n_bins == 1:
+        alpha = __et_relative_bw(bins_per_octave)
+    else:
+        alpha = filters._relative_bandwidth(freqs=freqs)
 
     lengths, filter_cutoff = filters.wavelet_lengths(
         freqs=freqs,
@@ -991,6 +1002,7 @@ def vqt(
 
         # This may be incorrect with early downsampling
         freqs_oct = freqs[sl]
+        alpha_oct = alpha[sl]
 
         fft_basis, n_fft, _ = __vqt_filter_fft(
             my_sr,
@@ -1001,7 +1013,7 @@ def vqt(
             window=window,
             gamma=gamma,
             dtype=dtype,
-            alpha=alpha,
+            alpha=alpha_oct,
         )
 
         # Re-scale the filters to compensate for downsampling
@@ -1478,8 +1490,14 @@ def griffinlim_cqt(
     )
 
 
-def __bpo_to_alpha(bins_per_octave: int) -> float:
-    """Compute the alpha coefficient for a given number of bins per octave
+def __et_relative_bw(bins_per_octave: int) -> np.ndarray:
+    """Compute the relative bandwidth coefficient for equal
+    (geometric) freuqency spacing and a give number of bins
+    per octave.
+
+    This is a special case of the more general `relative_bandwidth`
+    calculation that can be used when only a single basis frequency
+    is used.
 
     Parameters
     ----------
@@ -1487,8 +1505,9 @@ def __bpo_to_alpha(bins_per_octave: int) -> float:
 
     Returns
     -------
-    alpha : number > 0
+    alpha : np.ndarray > 0
+        Value is cast up to a 1d array to allow slicing
     """
 
     r = 2 ** (1 / bins_per_octave)
-    return (r**2 - 1) / (r**2 + 1)
+    return np.atleast_1d((r**2 - 1) / (r**2 + 1))
diff --git a/librosa/filters.py b/librosa/filters.py
--- a/librosa/filters.py
+++ b/librosa/filters.py
@@ -745,11 +745,11 @@ def wavelet_lengths(
             Hearing research 47.1-2 (1990): 103-138.
 
     alpha : number > 0 [optional]
-        If only one frequency is provided (``len(freqs)==1``), then filter bandwidth
-        cannot be computed.  In that case, the ``alpha`` parameter described above
-        can be explicitly specified here.
-
-        If two or more frequencies are provided, this parameter is ignored.
+        Optional pre-computed relative bandwidth parameter.
+        Note that this must be provided if ``len(freqs)==1`` because bandwidth cannot be
+        inferred from a single frequency.
+        Otherwise, if left unspecified, it will be automatically derived by the rules
+        specified above.
 
     Returns
     -------
@@ -792,20 +792,10 @@ def wavelet_lengths(
             f"Frequency array={freqs} must be in strictly ascending order"
         )
 
-    # We need at least 2 frequencies to infer alpha
-    if len(freqs) > 1:
-        # Approximate the local octave resolution
-        bpo = np.empty(len(freqs))
-        logf = np.log2(freqs)
-        bpo[0] = 1 / (logf[1] - logf[0])
-        bpo[-1] = 1 / (logf[-1] - logf[-2])
-        bpo[1:-1] = 2 / (logf[2:] - logf[:-2])
-
-        alpha = (2.0 ** (2 / bpo) - 1) / (2.0 ** (2 / bpo) + 1)
     if alpha is None:
-        raise ParameterError(
-            "Cannot construct a wavelet basis for a single frequency if alpha is not provided"
-        )
+        alpha = _relative_bandwidth(freqs=freqs)
+    else:
+        alpha = np.asarray(alpha)
 
     gamma_: Union[_FloatLike_co, np.ndarray]
     if gamma is None:
@@ -825,6 +815,40 @@ def wavelet_lengths(
     return lengths, f_cutoff
 
 
+def _relative_bandwidth(*, freqs: np.ndarray) -> np.ndarray:
+    """Compute the relative bandwidth for each of a set of specified frequencies.
+
+    This function is used as a helper in wavelet basis construction.
+
+    Parameters
+    ----------
+    freqs : np.ndarray
+        The array of frequencies
+
+    Returns
+    -------
+    alpha : np.ndarray
+        Relative bandwidth
+    """
+
+    if len(freqs) <= 1:
+        raise ParameterError(f"2 or more frequencies are required to compute bandwidths. Given freqs={freqs}")
+
+    # Approximate the local octave resolution around each frequency
+    bpo = np.empty_like(freqs)
+    logf = np.log2(freqs)
+    # Reflect at the lowest and highest frequencies
+    bpo[0] = 1 / (logf[1] - logf[0])
+    bpo[-1] = 1 / (logf[-1] - logf[-2])
+
+    # For everything else, do a centered difference
+    bpo[1:-1] = 2 / (logf[2:] - logf[:-2])
+
+    # Compute relative bandwidths
+    alpha = (2.0 ** (2 / bpo) - 1) / (2.0 ** (2 / bpo) + 1)
+    return alpha
+
+
 @cache(level=10)
 def wavelet(
     *,
@@ -878,11 +902,11 @@ def wavelet(
         By default, uses 64-bit (single precision) complex floating point.
 
     alpha : number > 0 [optional]
-        If only one frequency is provided (``len(freqs)==1``), then filter bandwidth
-        cannot be computed.  In that case, the ``alpha`` parameter described above
-        can be explicitly specified here.
-
-        If two or more frequencies are provided, this parameter is ignored.
+        Optional pre-computed relative bandwidth parameter.
+        Note that this must be provided if ``len(freqs)==1`` because bandwidth cannot be
+        inferred from a single frequency.
+        Otherwise, if left unspecified, it will be automatically derived by the rules
+        specified above.
 
     **kwargs : additional keyword arguments
         Arguments to `np.pad()` when ``pad==True``.
diff --git a/librosa/version.py b/librosa/version.py
--- a/librosa/version.py
+++ b/librosa/version.py
@@ -6,7 +6,7 @@
 import importlib
 
 short_version = "0.10"
-version = "0.10.0"
+version = "0.10.1dev"
 
 
 def __get_mod_version(modname):


Test patch:
diff --git a/tests/test_constantq.py b/tests/test_constantq.py
--- a/tests/test_constantq.py
+++ b/tests/test_constantq.py
@@ -243,7 +243,7 @@ def test_icqt_odd_hop(y_cqt_110, sr_cqt):
 
 
 @pytest.mark.parametrize("fmin", [None, librosa.note_to_hz("C2")])
-@pytest.mark.parametrize("n_bins", [12, 24])
+@pytest.mark.parametrize("n_bins", [1, 12, 24])
 @pytest.mark.parametrize("gamma", [None, 0, 2.5])
 @pytest.mark.parametrize("bins_per_octave", [12, 24])
 @pytest.mark.parametrize("tuning", [0])