# Source code for librosa.core.harmonic

```#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Harmonic calculations for frequency representations"""

import warnings

import numpy as np
import scipy.interpolate
import scipy.signal
from ..util.exceptions import ParameterError
from ..util import is_unique
from numpy.typing import ArrayLike
from typing import Callable, Optional, Sequence

__all__ = ["salience", "interp_harmonics", "f0_harmonics"]

[docs]def salience(
S: np.ndarray,
*,
freqs: np.ndarray,
harmonics: Sequence[float],
weights: Optional[ArrayLike] = None,
aggregate: Optional[Callable] = None,
filter_peaks: bool = True,
fill_value: float = np.nan,
kind: str = "linear",
axis: int = -2,
) -> np.ndarray:
"""Harmonic salience function.

Parameters
----------
S : np.ndarray [shape=(..., d, n)]
input time frequency magnitude representation (e.g. STFT or CQT magnitudes).
Must be real-valued and non-negative.

freqs : np.ndarray, shape=(S.shape[axis]) or shape=S.shape
The frequency values corresponding to S's elements along the
chosen axis.

Frequencies can also be time-varying, e.g. as computed by
`reassigned_spectrogram`, in which case the shape should
match ``S``.

harmonics : list-like, non-negative
Harmonics to include in salience computation.  The first harmonic (1)
corresponds to ``S`` itself. Values less than one (e.g., 1/2) correspond
to sub-harmonics.

weights : list-like
The weight to apply to each harmonic in the summation. (default:
uniform weights). Must be the same length as ``harmonics``.

aggregate : function
aggregation function (default: `np.average`)

If ``aggregate=np.average``, then a weighted average is
computed per-harmonic according to the specified weights.
For all other aggregation functions, all harmonics
are treated equally.

filter_peaks : bool
If true, returns harmonic summation only on frequencies of peak
magnitude. Otherwise returns harmonic summation over the full spectrum.
Defaults to True.

fill_value : float
The value to fill non-peaks in the output representation. (default:
`np.nan`) Only used if ``filter_peaks == True``.

kind : str
Interpolation type for harmonic estimation.
See `scipy.interpolate.interp1d`.

axis : int
The axis along which to compute harmonics

Returns
-------
S_sal : np.ndarray
``S_sal`` will have the same shape as ``S``, and measure
the overall harmonic energy at each frequency.

--------
interp_harmonics

Examples
--------
>>> y, sr = librosa.load(librosa.ex('trumpet'), duration=3)
>>> S = np.abs(librosa.stft(y))
>>> freqs = librosa.fft_frequencies(sr=sr)
>>> harms = [1, 2, 3, 4]
>>> weights = [1.0, 0.5, 0.33, 0.25]
>>> S_sal = librosa.salience(S, freqs=freqs, harmonics=harms, weights=weights, fill_value=0)
>>> print(S_sal.shape)
(1025, 115)
>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots(nrows=2, sharex=True, sharey=True)
>>> librosa.display.specshow(librosa.amplitude_to_db(S, ref=np.max),
...                          sr=sr, y_axis='log', x_axis='time', ax=ax)
>>> ax.set(title='Magnitude spectrogram')
>>> ax.label_outer()
>>> img = librosa.display.specshow(librosa.amplitude_to_db(S_sal,
...                                                        ref=np.max),
...                                sr=sr, y_axis='log', x_axis='time', ax=ax)
>>> ax.set(title='Salience spectrogram')
>>> fig.colorbar(img, ax=ax, format="%+2.0f dB")
"""
if aggregate is None:
aggregate = np.average

if weights is None:
weights = np.ones((len(harmonics),))
else:
weights = np.array(weights, dtype=float)

S_harm = interp_harmonics(S, freqs=freqs, harmonics=harmonics, kind=kind, axis=axis)

S_sal: np.ndarray
if aggregate is np.average:
S_sal = aggregate(S_harm, axis=axis - 1, weights=weights)
else:
S_sal = aggregate(S_harm, axis=axis - 1)

if filter_peaks:
S_peaks = scipy.signal.argrelmax(S, axis=axis)
S_out = np.empty(S.shape)
S_out.fill(fill_value)
S_out[S_peaks] = S_sal[S_peaks]

S_sal = S_out

return S_sal

[docs]def interp_harmonics(
x: np.ndarray,
*,
freqs: np.ndarray,
harmonics: ArrayLike,
kind: str = "linear",
fill_value: float = 0,
axis: int = -2,
) -> np.ndarray:
"""Compute the energy at harmonics of time-frequency representation.

Given a frequency-based energy representation such as a spectrogram
or tempogram, this function computes the energy at the chosen harmonics
of the frequency axis.  (See examples below.)
The resulting harmonic array can then be used as input to a salience
computation.

Parameters
----------
x : np.ndarray
The input energy
freqs : np.ndarray, shape=(x.shape[axis]) or shape=x.shape
The frequency values corresponding to x's elements along the
chosen axis.
Frequencies can also be time-varying, e.g. as computed by
`reassigned_spectrogram`, in which case the shape should
match ``x``.
harmonics : list-like, non-negative
Harmonics to compute as ``harmonics[i] * freqs``.
The first harmonic (1) corresponds to ``freqs``.
Values less than one (e.g., 1/2) correspond to sub-harmonics.
kind : str
Interpolation type.  See `scipy.interpolate.interp1d`.
fill_value : float
The value to fill when extrapolating beyond the observed
frequency range.
axis : int
The axis along which to compute harmonics

Returns
-------
x_harm : np.ndarray
``x_harm[i]`` will have the same shape as ``x``, and measure
the energy at the ``harmonics[i]`` harmonic of each frequency.
A new dimension indexing harmonics will be inserted immediately
before ``axis``.

--------
scipy.interpolate.interp1d

Examples
--------
Estimate the harmonics of a time-averaged tempogram

>>> # Compute the time-varying tempogram and average over time
>>> tempi = np.mean(librosa.feature.tempogram(y=y, sr=sr), axis=1)
>>> # We'll measure the first five harmonics
>>> harmonics = [1, 2, 3, 4, 5]
>>> f_tempo = librosa.tempo_frequencies(len(tempi), sr=sr)
>>> # Build the harmonic tensor; we only have one axis here (tempo)
>>> t_harmonics = librosa.interp_harmonics(tempi, freqs=f_tempo, harmonics=harmonics, axis=0)
>>> print(t_harmonics.shape)
(5, 384)

>>> # And plot the results
>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots()
>>> librosa.display.specshow(t_harmonics, x_axis='tempo', sr=sr, ax=ax)
>>> ax.set(yticks=np.arange(len(harmonics)),
...        yticklabels=['{:.3g}'.format(_) for _ in harmonics],
...        ylabel='Harmonic', xlabel='Tempo (BPM)')

We can also compute frequency harmonics for spectrograms.
To calculate sub-harmonic energy, use values < 1.

>>> y, sr = librosa.load(librosa.ex('trumpet'), duration=3)
>>> harmonics = [1./3, 1./2, 1, 2, 3, 4]
>>> S = np.abs(librosa.stft(y))
>>> fft_freqs = librosa.fft_frequencies(sr=sr)
>>> S_harm = librosa.interp_harmonics(S, freqs=fft_freqs, harmonics=harmonics, axis=0)
>>> print(S_harm.shape)
(6, 1025, 646)

>>> fig, ax = plt.subplots(nrows=3, ncols=2, sharex=True, sharey=True)
>>> for i, _sh in enumerate(S_harm):
...     img = librosa.display.specshow(librosa.amplitude_to_db(_sh,
...                                                      ref=S.max()),
...                              sr=sr, y_axis='log', x_axis='time',
...                              ax=ax.flat[i])
...     ax.flat[i].set(title='h={:.3g}'.format(harmonics[i]))
...     ax.flat[i].label_outer()
>>> fig.colorbar(img, ax=ax, format="%+2.f dB")
"""

if freqs.ndim == 1 and len(freqs) == x.shape[axis]:
# Build the 1-D interpolator.
# All frames have a common domain, so we only need one interpolator here.

# First, verify that the input frequencies are unique
if not is_unique(freqs, axis=0):
warnings.warn(
"Frequencies are not unique. This may produce incorrect harmonic interpolations.",
stacklevel=2,
)

f_interp = scipy.interpolate.interp1d(
freqs,
x,
axis=axis,
bounds_error=False,
copy=False,
kind=kind,
fill_value=fill_value,
)

# Set the interpolation points
f_out = np.multiply.outer(harmonics, freqs)

# Interpolate; suppress type checks
return f_interp(f_out)  # type: ignore

elif freqs.shape == x.shape:
if not np.all(is_unique(freqs, axis=axis)):
warnings.warn(
"Frequencies are not unique. This may produce incorrect harmonic interpolations.",
stacklevel=2,
)

# If we have time-varying frequencies, then it must match exactly the shape of the input

# We'll define a frame-wise interpolator helper function that we will vectorize over
# the entire input array
def _f_interp(_a, _b):
interp = scipy.interpolate.interp1d(
_a, _b, bounds_error=False, copy=False, kind=kind, fill_value=fill_value
)

return interp(np.multiply.outer(_a, harmonics))

# Signature is expanding frequency into a new dimension
xfunc = np.vectorize(_f_interp, signature="(f),(f)->(f,h)")

# Rotate the vectorizing axis to the tail so that we get parallelism over frames
# Afterward, we're swapping (-1, axis-1) instead of (-1,axis)
# because a new dimension has been inserted
return (  # type: ignore
xfunc(freqs.swapaxes(axis, -1), x.swapaxes(axis, -1))
.swapaxes(
# Return the original target axis to its place
-2,
axis,
)
.swapaxes(
# Put the new harmonic axis directly in front of the target axis
-1,
axis - 1,
)
)
else:
raise ParameterError(
f"freqs.shape={freqs.shape} is incompatible with input shape={x.shape}"
)

[docs]def f0_harmonics(
x: np.ndarray,
*,
f0: np.ndarray,
freqs: np.ndarray,
harmonics: ArrayLike,
kind: str = "linear",
fill_value: float = 0,
axis: int = -2,
) -> np.ndarray:
"""Compute the energy at selected harmonics of a time-varying
fundamental frequency.

This function can be used to reduce a `frequency * time` representation
to a `harmonic * time` representation, effectively normalizing out for
the fundamental frequency.  The result can be used as a representation
of timbre when f0 corresponds to pitch, or as a representation of
rhythm when f0 corresponds to tempo.

This function differs from `interp_harmonics`, which computes the
harmonics of *all* frequencies.

Parameters
----------
x : np.ndarray [shape=(..., frequencies, n)]
The input array (e.g., STFT magnitudes)
f0 : np.ndarray [shape=(..., n)]
The fundamental frequency (f0) of each frame in the input
Shape should match ``x.shape[-1]``
freqs : np.ndarray, shape=(x.shape[axis]) or shape=x.shape
The frequency values corresponding to X's elements along the
chosen axis.
Frequencies can also be time-varying, e.g. as computed by
`reassigned_spectrogram`, in which case the shape should
match ``x``.
harmonics : list-like, non-negative
Harmonics to compute as ``harmonics[i] * f0``
Values less than one (e.g., 1/2) correspond to sub-harmonics.
kind : str
Interpolation type.  See `scipy.interpolate.interp1d`.
fill_value : float
The value to fill when extrapolating beyond the observed
frequency range.
axis : int
The axis corresponding to frequency in ``x``

Returns
-------
f0_harm : np.ndarray [shape=(..., len(harmonics), n)]
Interpolated energy at each specified harmonic of the fundamental
frequency for each time step.

--------
interp_harmonics
librosa.feature.tempogram_ratio

Examples
--------
This example estimates the fundamental (f0), and then extracts the first
12 harmonics

>>> f0, voicing, voicing_p = librosa.pyin(y=y, sr=sr, fmin=200, fmax=700)
>>> S = np.abs(librosa.stft(y))
>>> freqs = librosa.fft_frequencies(sr=sr)
>>> harmonics = np.arange(1, 13)
>>> f0_harm = librosa.f0_harmonics(S, freqs=freqs, f0=f0, harmonics=harmonics)

>>> import matplotlib.pyplot as plt
>>> fig, ax =plt.subplots(nrows=2, sharex=True)
>>> librosa.display.specshow(librosa.amplitude_to_db(S, ref=np.max),
...                          x_axis='time', y_axis='log', ax=ax)
>>> times = librosa.times_like(f0)
>>> for h in harmonics:
...     ax.plot(times, h * f0, label=f"{h}*f0")
>>> ax.legend(ncols=4, loc='lower right')
>>> ax.label_outer()
>>> librosa.display.specshow(librosa.amplitude_to_db(f0_harm, ref=np.max),
...                          x_axis='time', ax=ax)
>>> ax.set_yticks(harmonics-1)
>>> ax.set_yticklabels(harmonics)
>>> ax.set(ylabel='Harmonics')
"""

result: np.ndarray
if freqs.ndim == 1 and len(freqs) == x.shape[axis]:
if not is_unique(freqs, axis=0):
warnings.warn(
"Frequencies are not unique. This may produce incorrect harmonic interpolations.",
stacklevel=2,
)

# We have a fixed frequency grid
idx = np.isfinite(freqs)

def _f_interps(data, f):
interp = scipy.interpolate.interp1d(
freqs[idx],
data[idx],
axis=0,
bounds_error=False,
copy=False,
assume_sorted=False,
kind=kind,
fill_value=fill_value,
)
return interp(f)

xfunc = np.vectorize(_f_interps, signature="(f),(h)->(h)")
result = xfunc(x.swapaxes(axis, -1), np.multiply.outer(f0, harmonics)).swapaxes(
axis, -1
)

elif freqs.shape == x.shape:
if not np.all(is_unique(freqs, axis=axis)):
warnings.warn(
"Frequencies are not unique. This may produce incorrect harmonic interpolations.",
stacklevel=2,
)

# We have a dynamic frequency grid, not so bad
def _f_interpd(data, frequencies, f):
idx = np.isfinite(frequencies)
interp = scipy.interpolate.interp1d(
frequencies[idx],
data[idx],
axis=0,
bounds_error=False,
copy=False,
assume_sorted=False,
kind=kind,
fill_value=fill_value,
)
return interp(f)

xfunc = np.vectorize(_f_interpd, signature="(f),(f),(h)->(h)")
result = xfunc(
x.swapaxes(axis, -1),
freqs.swapaxes(axis, -1),
np.multiply.outer(f0, harmonics),
).swapaxes(axis, -1)

else:
raise ParameterError(
f"freqs.shape={freqs.shape} is incompatible with input shape={x.shape}"
)

return np.nan_to_num(result, copy=False, nan=fill_value)
```