#!/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.
See Also
--------
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[0])
>>> ax[0].set(title='Magnitude spectrogram')
>>> ax[0].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[1])
>>> ax[1].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``.
See Also
--------
scipy.interpolate.interp1d
Examples
--------
Estimate the harmonics of a time-averaged tempogram
>>> y, sr = librosa.load(librosa.ex('sweetwaltz'))
>>> # 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.
See Also
--------
interp_harmonics
librosa.feature.tempogram_ratio
Examples
--------
This example estimates the fundamental (f0), and then extracts the first
12 harmonics
>>> y, sr = librosa.load(librosa.ex('trumpet'))
>>> 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[0])
>>> times = librosa.times_like(f0)
>>> for h in harmonics:
... ax[0].plot(times, h * f0, label=f"{h}*f0")
>>> ax[0].legend(ncols=4, loc='lower right')
>>> ax[0].label_outer()
>>> librosa.display.specshow(librosa.amplitude_to_db(f0_harm, ref=np.max),
... x_axis='time', ax=ax[1])
>>> ax[1].set_yticks(harmonics-1)
>>> ax[1].set_yticklabels(harmonics)
>>> ax[1].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)