Repository: librosa/librosa

Issue:
Vectorize note converters
**Is your feature request related to a problem? Please describe.**

Our unit converters are generally pretty liberal about preserving shapes of input.  For example, a matrix of midi numbers can be converted into a matrix of frequencies (Hz):

```python
In [4]: midis = np.random.randint(0, 128, size=(5, 10))

In [5]: midis
Out[5]: 
array([[ 12,  51,  30,  56,  54,  12,  44,  72,  95,  82],
       [ 48,  91,  68,  97,  65,  15,  93, 102,  86,  59],
       [  0,  32, 116,  94, 115, 108,  30, 112,  88,  54],
       [ 75,   4, 121,  31,  91,  22,  92, 127,  15,  23],
       [ 96,  37,   2, 105, 106,  84,  99, 109,  46,  87]])

In [6]: librosa.midi_to_hz(midis)
Out[6]: 
array([[1.63515978e+01, 1.55563492e+02, 4.62493028e+01, 2.07652349e+02,
        1.84997211e+02, 1.63515978e+01, 1.03826174e+02, 5.23251131e+02,
        1.97553321e+03, 9.32327523e+02],
       [1.30812783e+02, 1.56798174e+03, 4.15304698e+02, 2.21746105e+03,
        3.49228231e+02, 1.94454365e+01, 1.76000000e+03, 2.95995538e+03,
        1.17465907e+03, 2.46941651e+02],
       [8.17579892e+00, 5.19130872e+01, 6.64487516e+03, 1.86465505e+03,
        6.27192698e+03, 4.18600904e+03, 4.62493028e+01, 5.27404091e+03,
        1.31851023e+03, 1.84997211e+02],
       [6.22253967e+02, 1.03008612e+01, 8.86984419e+03, 4.89994295e+01,
        1.56798174e+03, 2.91352351e+01, 1.66121879e+03, 1.25438540e+04,
        1.94454365e+01, 3.08677063e+01],
       [2.09300452e+03, 6.92956577e+01, 9.17702400e+00, 3.52000000e+03,
        3.72931009e+03, 1.04650226e+03, 2.48901587e+03, 4.43492210e+03,
        1.16540940e+02, 1.24450793e+03]])
```
This works because the conversion is defined entirely by numpy operations.

When it comes to note conversion, we're not quite as flexible.  For example, in midi_to_note, we detect if the input is scalar, and if not, we construct a list and recursively apply the function to each element of the list:
https://github.com/librosa/librosa/blob/04b9d62a9abdacdf025674413545dafb84e09a1a/librosa/core/convert.py#L648-L652

The result is a list-of-lists:

```python
In [7]: librosa.midi_to_note(midis)
Out[7]: 
[
    ['C0', 'D♯3', 'F♯1', 'G♯3', 'F♯3', 'C0', 'G♯2', 'C5', 'B6', 'A♯5'],
    ['C3', 'G6', 'G♯4', 'C♯7', 'F4', 'D♯0', 'A6', 'F♯7', 'D6', 'B3'],
    ['C-1', 'G♯1', 'G♯8', 'A♯6', 'G8', 'C8', 'F♯1', 'E8', 'E6', 'F♯3'],
    ['D♯5', 'E-1', 'C♯9', 'G1', 'G6', 'A♯0', 'G♯6', 'G9', 'D♯0', 'B0'],
    ['C7', 'C♯2', 'D-1', 'A7', 'A♯7', 'C6', 'D♯7', 'C♯8', 'A♯2', 'D♯6']
]
```

This is okay for most applications, but A) it's a little hacky, and B) it makes the output types difficult to reason about.  


**Describe the solution you'd like**

Instead of handling this directly, we could use `np.vectorize` to handle the dispatching automatically.  For example, midi_to_note could be rewritten as a scalar helper function that vectorizes as follows:

```python
def _midi_to_note(midi, *, octave=True, cents=False, key="C:maj", unicode=True):
    
    if cents and not octave:
        raise ParameterError("Cannot encode cents without octave information.")

    note_map = notation.key_to_notes(key=key, unicode=unicode)

    note_num = int(np.round(midi))
    note_cents = int(100 * np.around(midi - note_num, 2))

    note = note_map[note_num % 12]

    if octave:
        note = "{:s}{:0d}".format(note, int(note_num / 12) - 1)
    if cents:
        note = "{:s}{:+02d}".format(note, note_cents)

    return note

midi_to_note = np.vectorize(_midi_to_note, otypes='U', excluded=set(['octave', 'cents', 'key', 'unicode']))
```
Then, continuing the example above, we would have
```python
>>> midi_to_note(midis)
array([['C0', 'D♯3', 'F♯1', 'G♯3', 'F♯3', 'C0', 'G♯2', 'C5', 'B6', 'A♯5'],
       ['C3', 'G6', 'G♯4', 'C♯7', 'F4', 'D♯0', 'A6', 'F♯7', 'D6', 'B3'],
       ['C-1', 'G♯1', 'G♯8', 'A♯6', 'G8', 'C8', 'F♯1', 'E8', 'E6', 'F♯3'],
       ['D♯5', 'E-1', 'C♯9', 'G1', 'G6', 'A♯0', 'G♯6', 'G9', 'D♯0', 'B0'],
       ['C7', 'C♯2', 'D-1', 'A7', 'A♯7', 'C6', 'D♯7', 'C♯8', 'A♯2',
        'D♯6']], dtype='<U3')
```
(unicode-dtype numpy array instead of list-of-lists).

There's still a bit of a snag here, in that a scalar input will produce a (0-dimensional) array output rather than collapsing back to scalar.  I don't know if there's a recommended workaround for this, but it's a little annoying.  Worst case, we could make a little decorator-wrapper that detects this case and returns `.item()` if the input was not a np array.

This would be an API-breaking change, but I think it's worth considering, especially as we may start adding more numeric->string converters in the near future.


Discussion:
#1552 includes a hack at this idea for the `hz_to_fjs` converter.  The basic idea works, but I think if we go this route, it will be better to implement a vectorization decorator that does two things:

1. Uses functools to correct the call signature
2. Handles scalar unpacking so we get `scalar→scalar` and `array→array`

As is, we get:

```python
Signature:       librosa.notation.interval_to_fjs(*args, **kwargs)
Type:            vectorize
String form:     <numpy.vectorize object at 0x7f7d0e5d4520>
File:            ~/miniconda/envs/py39/lib/python3.9/site-packages/numpy/__init__.py
```
when probing the vectorized function.  The underlying python function probes as
```python
Signature:
librosa.notation._interval_to_fjs(
    interval,
    *,
    unison='C',
    tolerance=1.0317460317460319,
    unicode=True,
)
Source:   
...
```
And as it turns out, both of these issues are already up (and together!) at numpy: https://github.com/numpy/numpy/issues/21274
Ok, this seems to do the job nicely:

```python
import numpy as np
import functools

# Example function to vectorize
def _myfunc(x, fmtstr='g'):
    '''Convert an integer to a string
    
    Parameters
    ----------
    x : a number or something
    
    fmtstr : the format string
    
    Returns
    -------
    y : a string or something
    '''
    
    fmt = r'{:' + fmtstr + r'}'
    return fmt.format(x)

# Vectorize utility
def myvectorize(function, otypes=None, doc=None, excluded=None, cache=False, signature=None):
    vecfunc = np.vectorize(function, otypes=otypes, doc=doc, excluded=excluded, cache=cache, signature=signature)
    
    def _vec(*args, **kwargs):
        y = vecfunc(*args, **kwargs)
        if np.isscalar(args[0]):
            return y.item()
        else:
            return y
    _vec = functools.update_wrapper(_vec, function)
    
    return _vec

# Example usage
myfunc = myvectorize(_myfunc, otypes='U', excluded='fmstr')
```

When called on an iterable, it does the right thing:
```python
>>> myfunc([[5, 6, 7], [8, 9, 10]])
array([['5', '6', '7'],
       ['8', '9', '10']], dtype='<U2')
```
and likewise with a scalar input:
```python
>>> myfunc(5, fmtstr='x')
myfunc(5, fmtstr='x')
'5'
```
And the signature, docstrings, etc are all correctly propagated:

```python
>>> myfunc?
Signature: myfunc(x, fmtstr='g')
Docstring:
Convert an integer to a string

Parameters
----------
x : a number or something

fmtstr : the format string

Returns
-------
y : a string or something
File:      /tmp/ipykernel_160743/4240084744.py
Type:      function
```

Resolving patch:
diff --git a/librosa/core/convert.py b/librosa/core/convert.py
--- a/librosa/core/convert.py
+++ b/librosa/core/convert.py
@@ -6,6 +6,7 @@
 import numpy as np
 from . import notation
 from ..util.exceptions import ParameterError
+from ..util.decorators import vectorize
 
 __all__ = [
     "frames_to_samples",
@@ -569,6 +570,7 @@ def note_to_midi(note, *, round_midi=True):
     return note_value
 
 
+@vectorize(excluded=['octave', 'cents', 'key', 'unicode'])
 def midi_to_note(midi, *, octave=True, cents=False, key="C:maj", unicode=True):
     """Convert one or more MIDI numbers to note strings.
 
@@ -596,13 +598,15 @@ def midi_to_note(midi, *, octave=True, cents=False, key="C:maj", unicode=True):
     >>> librosa.midi_to_note(104.7, cents=True)
     'A7-30'
 
-    >>> librosa.midi_to_note(list(range(12, 24)))
-    ['C0', 'C♯0', 'D0', 'D♯0', 'E0', 'F0', 'F♯0', 'G0', 'G♯0', 'A0', 'A♯0', 'B0']
+    >>> librosa.midi_to_note(np.arange(12, 24)))
+    array(['C0', 'C♯0', 'D0', 'D♯0', 'E0', 'F0', 'F♯0', 'G0', 'G♯0', 'A0',
+           'A♯0', 'B0'], dtype='<U3')
 
     Use a key signature to resolve enharmonic equivalences
 
     >>> librosa.midi_to_note(range(12, 24), key='F:min')
-    ['C0', 'D♭0', 'D0', 'E♭0', 'E0', 'F0', 'G♭0', 'G0', 'A♭0', 'A0', 'B♭0', 'B0']
+    array(['C0', 'D♭0', 'D0', 'E♭0', 'E0', 'F0', 'G♭0', 'G0', 'A♭0', 'A0',
+           'B♭0', 'B0'], dtype='<U3')
 
     Parameters
     ----------
@@ -626,7 +630,7 @@ def midi_to_note(midi, *, octave=True, cents=False, key="C:maj", unicode=True):
 
     Returns
     -------
-    notes : str or iterable of str
+    notes : str or np.ndarray of str
         Strings describing each midi note.
 
     Raises
@@ -645,12 +649,6 @@ def midi_to_note(midi, *, octave=True, cents=False, key="C:maj", unicode=True):
     if cents and not octave:
         raise ParameterError("Cannot encode cents without octave information.")
 
-    if not np.isscalar(midi):
-        return [
-            midi_to_note(x, octave=octave, cents=cents, key=key, unicode=unicode)
-            for x in midi
-        ]
-
     note_map = notation.key_to_notes(key=key, unicode=unicode)
 
     note_num = int(np.round(midi))
@@ -740,7 +738,7 @@ def hz_to_note(frequencies, **kwargs):
 
     Returns
     -------
-    notes : list of str
+    notes : str or np.ndarray of str
         ``notes[i]`` is the closest note name to ``frequency[i]``
         (or ``frequency`` if the input is scalar)
 
@@ -755,7 +753,7 @@ def hz_to_note(frequencies, **kwargs):
     Get a single note name for a frequency
 
     >>> librosa.hz_to_note(440.0)
-    ['A5']
+    'A5'
 
     Get multiple notes with cent deviation
 
@@ -1680,6 +1678,7 @@ def samples_like(X, *, hop_length=512, n_fft=None, axis=-1):
     return frames_to_samples(frames, hop_length=hop_length, n_fft=n_fft)
 
 
+@vectorize(excluded=['Sa', 'abbr', 'octave', 'unicode'])
 def midi_to_svara_h(midi, *, Sa, abbr=True, octave=True, unicode=True):
     """Convert MIDI numbers to Hindustani svara
 
@@ -1710,7 +1709,7 @@ def midi_to_svara_h(midi, *, Sa, abbr=True, octave=True, unicode=True):
 
     Returns
     -------
-    svara : str or list of str
+    svara : str or np.ndarray of str
         The svara corresponding to the given MIDI number(s)
 
     See Also
@@ -1722,25 +1721,30 @@ def midi_to_svara_h(midi, *, Sa, abbr=True, octave=True, unicode=True):
 
     Examples
     --------
+    Convert a single midi number:
+
+    >>> librosa.midi_to_svara_h(65, Sa=60)
+    'm'
+
     The first three svara with Sa at midi number 60:
 
-    >>> librosa.midi_svara_h([60, 61, 62], Sa=60)
-    ['S', 'r', 'R']
+    >>> librosa.midi_to_svara_h([60, 61, 62], Sa=60)
+    array(['S', 'r', 'R'], dtype='<U1')
 
     With Sa=67, midi 60-62 are in the octave below:
 
     >>> librosa.midi_to_svara_h([60, 61, 62], Sa=67)
-    ['ṃ', 'Ṃ', 'P̣']
+    array(['ṃ', 'Ṃ', 'P̣'], dtype='<U2')
 
     Or without unicode decoration:
 
     >>> librosa.midi_to_svara_h([60, 61, 62], Sa=67, unicode=False)
-    ['m,', 'M,', 'P,']
+    array(['m,', 'M,', 'P,'], dtype='<U2')
 
     Or going up an octave, with Sa=60, and using unabbreviated notes
 
     >>> librosa.midi_to_svara_h([72, 73, 74], Sa=60, abbr=False)
-    ['Ṡa', 'ṙe', 'Ṙe']
+    array(['Ṡa', 'ṙe', 'Ṙe'], dtype='<U3')
     """
 
     SVARA_MAP = [
@@ -1760,12 +1764,6 @@ def midi_to_svara_h(midi, *, Sa, abbr=True, octave=True, unicode=True):
 
     SVARA_MAP_SHORT = list(s[0] for s in SVARA_MAP)
 
-    if not np.isscalar(midi):
-        return [
-            midi_to_svara_h(m, Sa=Sa, abbr=abbr, octave=octave, unicode=unicode)
-            for m in midi
-        ]
-
     svara_num = int(np.round(midi - Sa))
 
     if abbr:
@@ -1820,7 +1818,7 @@ def hz_to_svara_h(frequencies, *, Sa, abbr=True, octave=True, unicode=True):
 
     Returns
     -------
-    svara : str or list of str
+    svara : str or np.ndarray of str
         The svara corresponding to the given frequency/frequencies
 
     See Also
@@ -1857,7 +1855,7 @@ def note_to_svara_h(notes, *, Sa, abbr=True, octave=True, unicode=True):
 
     Parameters
     ----------
-    notes : str or list of str
+    notes : str or iterable of str
         Notes to convert (e.g., `'C#'` or `['C4', 'Db4', 'D4']`
 
     Sa : str
@@ -1885,7 +1883,7 @@ def note_to_svara_h(notes, *, Sa, abbr=True, octave=True, unicode=True):
 
     Returns
     -------
-    svara : str or list of str
+    svara : str or np.ndarray of str
         The svara corresponding to the given notes
 
     See Also
@@ -1909,6 +1907,7 @@ def note_to_svara_h(notes, *, Sa, abbr=True, octave=True, unicode=True):
     )
 
 
+@vectorize(excluded=['Sa', 'mela', 'abbr', 'octave', 'unicode'])
 def midi_to_svara_c(midi, *, Sa, mela, abbr=True, octave=True, unicode=True):
     """Convert MIDI numbers to Carnatic svara within a given melakarta raga
 
@@ -1943,7 +1942,7 @@ def midi_to_svara_c(midi, *, Sa, mela, abbr=True, octave=True, unicode=True):
 
     Returns
     -------
-    svara : str or list of str
+    svara : str or np.ndarray of str
         The svara corresponding to the given MIDI number(s)
 
     See Also
@@ -1954,14 +1953,6 @@ def midi_to_svara_c(midi, *, Sa, mela, abbr=True, octave=True, unicode=True):
     mela_to_svara
     list_mela
     """
-    if not np.isscalar(midi):
-        return [
-            midi_to_svara_c(
-                m, Sa=Sa, mela=mela, abbr=abbr, octave=octave, unicode=unicode
-            )
-            for m in midi
-        ]
-
     svara_num = int(np.round(midi - Sa))
 
     svara_map = notation.mela_to_svara(mela, abbr=abbr, unicode=unicode)
@@ -1983,6 +1974,7 @@ def midi_to_svara_c(midi, *, Sa, mela, abbr=True, octave=True, unicode=True):
     return svara
 
 
+
 def hz_to_svara_c(frequencies, *, Sa, mela, abbr=True, octave=True, unicode=True):
     """Convert frequencies (in Hz) to Carnatic svara
 
@@ -2018,7 +2010,7 @@ def hz_to_svara_c(frequencies, *, Sa, mela, abbr=True, octave=True, unicode=True
 
     Returns
     -------
-    svara : str or list of str
+    svara : str or np.ndarray of str
         The svara corresponding to the given frequency/frequencies
 
     See Also
@@ -2056,7 +2048,7 @@ def note_to_svara_c(notes, *, Sa, mela, abbr=True, octave=True, unicode=True):
 
     Parameters
     ----------
-    notes : str or list of str
+    notes : str or iterable of str
         Notes to convert (e.g., `'C#'` or `['C4', 'Db4', 'D4']`
 
     Sa : str
@@ -2087,7 +2079,7 @@ def note_to_svara_c(notes, *, Sa, mela, abbr=True, octave=True, unicode=True):
 
     Returns
     -------
-    svara : str or list of str
+    svara : str or np.ndarray of str
         The svara corresponding to the given notes
 
     See Also
diff --git a/librosa/util/decorators.py b/librosa/util/decorators.py
--- a/librosa/util/decorators.py
+++ b/librosa/util/decorators.py
@@ -4,10 +4,12 @@
 """Helpful tools for deprecation"""
 
 import warnings
+import functools
 from decorator import decorator
+import numpy as np
 
 
-__all__ = ["moved", "deprecated"]
+__all__ = ["moved", "deprecated", "vectorize"]
 
 
 def moved(*, moved_from, version, version_removed):
@@ -53,3 +55,24 @@ def __wrapper(func, *args, **kwargs):
         return func(*args, **kwargs)
 
     return decorator(__wrapper)
+
+
+def vectorize(*, otypes=None, doc=None, excluded=None, cache=False, signature=None):
+    """This function is not quite a decorator, but is used as a wrapper
+    to np.vectorize that preserves scalar behavior.
+    """
+
+    def __wrapper(function):
+        vecfunc = np.vectorize(function, otypes=otypes, doc=doc, excluded=excluded, cache=cache, signature=signature)
+
+        @functools.wraps(function)
+        def _vec(*args, **kwargs):
+            y = vecfunc(*args, **kwargs)
+            if np.isscalar(args[0]):
+                return y.item()
+            else:
+                return y
+
+        return _vec
+
+    return __wrapper


Test patch:
diff --git a/tests/test_convert.py b/tests/test_convert.py
--- a/tests/test_convert.py
+++ b/tests/test_convert.py
@@ -293,7 +293,10 @@ def test_hz_to_midi():
 )
 def test_hz_to_note(hz, note, octave, cents):
     note_out = librosa.hz_to_note(hz, octave=octave, cents=cents)
-    assert note_out == note
+    if np.isscalar(hz):
+        assert note_out == note
+    else:
+        assert np.all(note_out == note)
 
 
 @pytest.mark.xfail(raises=librosa.ParameterError)