Source code for librosa.display.formatting
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Tick formatters and axis decoration
===================================
This module provides specialized Matplotlib formatters and tick locators for
musical and time/frequency axes. It includes utilities for formatting time,
frequencies (linear and log), musical notes, chords, and chromatic scales.
"""
# mypy: disable-error-code="attr-defined"
# Standard library imports for type checking and future compatibility
from __future__ import annotations
import warnings
# Third-party imports for plotting and numerical operations
from fractions import Fraction
from itertools import product
from typing import TYPE_CHECKING, Any, cast
import matplotlib.axes as mplaxes
import matplotlib.colors as colors
import matplotlib.pyplot as plt
import matplotlib.ticker as mplticker
import matplotlib.transforms as mtransforms
import numpy as np
from matplotlib import colormaps as mcm
# Core and utility imports for audio processing
from .. import core, util
from ..util.decorators import moved
from ..util.exceptions import ParameterError
# Type checking imports for development and static analysis
if TYPE_CHECKING:
from typing import Any, Collection, Sequence
import matplotlib
import matplotlib.axes
import matplotlib.figure
import scipy.interpolate
from matplotlib.colors import Colormap
from .._typing import ArrayLike, _Array1D, _FloatLike_co
# Nominal center frequencies for oct3 bands
_OCT3_FREQUENCIES = np.array(
[
31.5,
40,
50,
63,
80,
100,
125,
160,
200,
250,
315,
400,
500,
630,
800,
1000,
1250,
1600,
2000,
2500,
3150,
4000,
5000,
6300,
8000,
10000,
12500,
16000,
20000,
25000,
# --- ultrasonic up to 800KHz
31500,
40000,
50000,
63000,
80000,
100000,
125000,
160000,
200000,
250000,
315000,
400000,
500000,
630000,
800000,
]
)
[docs]
class TimeFormatter(mplticker.Formatter):
"""A tick formatter for time axes.
Automatically switches between seconds, minutes:seconds,
or hours:minutes:seconds.
Parameters
----------
lag : bool
If ``True``, then the time axis is interpreted in lag coordinates.
Anything past the midpoint will be converted to negative time.
unit : str or None
Abbreviation of the string representation for axis labels and ticks.
List of supported units:
* `"h"`: hour-based format (`H:MM:SS`)
* `"m"`: minute-based format (`M:SS`)
* `"s"`: second-based format (`S.sss` in scientific notation)
* `"ms"`: millisecond-based format (`s.µµµ` in scientific notation)
* `None`: adaptive to the duration of the underlying time range: similar
to `"h"` above 3600 seconds; to `"m"` between 60 and 3600 seconds; to
`"s"` between 1 and 60 seconds; and to `"ms"` below 1 second.
See Also
--------
matplotlib.ticker.Formatter
Examples
--------
For normal time
>>> import matplotlib.pyplot as plt
>>> times = np.arange(30)
>>> values = np.random.randn(len(times))
>>> fig, ax = plt.subplots()
>>> ax.plot(times, values)
>>> ax.xaxis.set_major_formatter(librosa.display.TimeFormatter())
>>> ax.set(xlabel='Time')
>>> plt.show()
Manually set the physical time unit of the x-axis to milliseconds
>>> times = np.arange(100)
>>> values = np.random.randn(len(times))
>>> fig, ax = plt.subplots()
>>> ax.plot(times, values)
>>> ax.xaxis.set_major_formatter(librosa.display.TimeFormatter(unit='ms'))
>>> ax.set(xlabel='Time (ms)')
>>> plt.show()
For lag plots
>>> times = np.arange(60)
>>> values = np.random.randn(len(times))
>>> fig, ax = plt.subplots()
>>> ax.plot(times, values)
>>> ax.xaxis.set_major_formatter(librosa.display.TimeFormatter(lag=True))
>>> ax.set(xlabel='Lag')
>>> plt.show()
"""
unit: str | None
lag: bool
[docs]
def __init__(self, lag: bool = False, unit: str | None = None):
if unit not in ["h", "m", "s", "ms", None]:
raise ParameterError(f"Unknown time unit: {unit}")
super().__init__()
self.unit = unit
self.lag = lag
def __call__(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Return the time format as pos"""
assert self.axis is not None
_, dmax = self.axis.get_data_interval()
vmin, vmax = self.axis.get_view_interval()
# In lag-time axes, anything greater than dmax / 2 is negative time
if self.lag and x >= dmax * 0.5:
# In lag mode, don't tick past the limits of the data
if x > dmax:
return ""
value = np.abs(x - dmax)
# Do we need to tweak vmin/vmax here?
sign = "-"
else:
value = x
sign = ""
if self.unit == "h" or ((self.unit is None) and (vmax - vmin > 3600)):
s = "{:d}:{:02d}:{:02d}".format(
int(value / 3600.0),
int(np.mod(value / 60.0, 60)),
int(np.mod(value, 60)),
)
elif self.unit == "m" or ((self.unit is None) and (vmax - vmin > 60)):
s = "{:d}:{:02d}".format(int(value / 60.0), int(np.mod(value, 60)))
elif self.unit == "s":
s = f"{value:.3g}"
elif self.unit is None and (vmax - vmin >= 1):
s = f"{value:.2g}"
elif self.unit == "ms":
s = "{:.3g}".format(value * 1000)
elif self.unit is None and (vmax - vmin < 1):
s = f"{value:.3f}"
return f"{sign:s}{s:s}"
class AdaptiveFormatterBase(mplticker.Formatter):
"""Base formatter handling 2-octave span suppression.
Subclasses must implement `_format_tick`.
Parameters
----------
major : bool
If ``True``, ticks are always labeled.
If ``False``, ticks are only labeled if the span is less than 2 octaves.
"""
major: bool
vmin: float | None
vmax: float | None
def __init__(self, major: bool = True):
super().__init__()
self.major = major
self.vmin = None
self.vmax = None
def __call__(self, x: float, pos: int | None = None) -> str:
"""Apply the bounds check, then delegate to subclass formatting."""
if x <= 0:
return ""
assert self.axis is not None
vmin, vmax = self.axis.get_view_interval()
# Handle inverted axes
self.vmin, self.vmax = (vmin, vmax) if vmin <= vmax else (vmax, vmin)
if not self.major and self.vmax > 4 * max(1, self.vmin):
return ""
return self._format_tick(x, pos)
def _format_tick(self, x: float, pos: int | None = None) -> str:
raise NotImplementedError
[docs]
class NoteFormatter(AdaptiveFormatterBase):
"""Ticker formatter for Notes
Parameters
----------
octave : bool
If ``True``, display the octave number along with the note name.
Otherwise, only show the note name (and cent deviation)
major : bool
If ``True``, ticks are always labeled.
If ``False``, ticks are only labeled if the span is less than 2 octaves
key : str
Key for determining pitch spelling.
unicode : bool
If ``True``, use unicode symbols for accidentals.
If ``False``, use ASCII symbols for accidentals.
See Also
--------
LogHzFormatter
matplotlib.ticker.Formatter
"""
octave: bool
major: bool
key: str
unicode: bool
[docs]
def __init__(
self,
octave: bool = True,
major: bool = True,
key: str = "C:maj",
unicode: bool = True,
):
super().__init__(major=major)
self.octave = octave
self.key = key
self.unicode = unicode
def _format_tick(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Apply the formatter to position"""
# Only use cent precision if our vspan is less than an octave
assert self.vmax is not None and self.vmin is not None
cents = self.vmax <= 2 * max(1, self.vmin)
return core.hz_to_note(x, octave=self.octave, cents=cents, key=self.key, unicode=self.unicode)
[docs]
class SvaraFormatter(AdaptiveFormatterBase):
"""Ticker formatter for Svara
Parameters
----------
Sa : number > 0
Frequency (in Hz) of Sa
octave : bool
If ``True``, display the octave number along with the note name.
Otherwise, only show the note name (and cent deviation)
major : bool
If ``True``, ticks are always labeled.
If ``False``, ticks are only labeled if the span is less than 2 octaves
abbr : bool
If ``True``, use abbreviated svara names.
If ``False``, use full svara names.
mela : str or int
For Carnatic svara, the index or name of the melakarta raga in question
To use Hindustani svara, set ``mela=None``
unicode : bool
If ``True``, use unicode symbols for accidentals.
If ``False``, use ASCII symbols for accidentals.
See Also
--------
NoteFormatter
matplotlib.ticker.Formatter
librosa.hz_to_svara_c
librosa.hz_to_svara_h
"""
[docs]
def __init__(
self,
Sa: float,
octave: bool = True,
major: bool = True,
abbr: bool = False,
mela: str | int | None = None,
unicode: bool = True,
):
if Sa is None:
raise ParameterError("Sa frequency is required for svara display formatting")
super().__init__(major=major)
self.Sa = Sa
self.octave = octave
self.abbr = abbr
self.mela = mela
self.unicode = unicode
def _format_tick(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
if self.mela is None:
return core.hz_to_svara_h(x, Sa=self.Sa, octave=self.octave, abbr=self.abbr, unicode=self.unicode)
else:
return core.hz_to_svara_c(
x,
Sa=self.Sa,
mela=self.mela,
octave=self.octave,
abbr=self.abbr,
unicode=self.unicode,
)
[docs]
class FJSFormatter(AdaptiveFormatterBase):
"""Ticker formatter for Functional Just System (FJS) notation
Parameters
----------
fmin : float
The unison frequency for this axis
n_bins : int > 0
The number of frequency bins.
bins_per_octave : int > 0
The number of bins per octave.
intervals : str or array of float in [1, 2)
The interval specification for the frequency axis.
See `core.interval_frequencies` for supported values.
major : bool
If ``True``, ticks are always labeled.
If ``False``, ticks are only labeled if the span is less than 2 octaves
unison : str
The unison note name. If not provided, it will be inferred from fmin.
unicode : bool
If ``True``, use unicode symbols for accidentals.
If ``False``, use ASCII symbols for accidentals.
See Also
--------
NoteFormatter
hz_to_fjs
matplotlib.ticker.Formatter
"""
fmin: float
unison: str | None
unicode: bool
intervals: str | Collection[float]
n_bins: int
bins_per_octave: int
frequencies_: np.ndarray[tuple[int], np.dtype[np.float64]]
[docs]
def __init__(
self,
*,
fmin: float,
n_bins: int,
bins_per_octave: int,
intervals: str | Collection[float],
major: bool = True,
unison: str | None = None,
unicode: bool = True,
):
super().__init__(major=major)
self.fmin = fmin
self.unison = unison
self.unicode = unicode
self.intervals = intervals
self.n_bins = n_bins
self.bins_per_octave = bins_per_octave
self.frequencies_ = core.interval_frequencies(
n_bins, fmin=fmin, intervals=intervals, bins_per_octave=bins_per_octave
)
def _format_tick(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Apply the formatter to position"""
# Map the given frequency to the nearest JI interval
idx = util.match_events(np.atleast_1d(x), self.frequencies_)[0]
label: str = core.hz_to_fjs(
self.frequencies_[idx],
fmin=self.fmin,
unison=self.unison,
unicode=self.unicode,
)
return label
[docs]
class LogHzFormatter(AdaptiveFormatterBase):
"""Ticker formatter for logarithmic frequency
Parameters
----------
major : bool
If ``True``, ticks are always labeled.
If ``False``, ticks are only labeled if the span is less than 2 octaves
See Also
--------
NoteFormatter
matplotlib.ticker.Formatter
"""
def _format_tick(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Apply the formatter to position"""
return f"{x:g}"
class AdaptiveEngFormatter(AdaptiveFormatterBase):
"""Engineering formatter that limits tick labels to a 2-octave span.
Parameters
----------
major : bool
If ``True``, ticks are always labeled.
If ``False``, ticks are only labeled if the span is less than 2 octaves
**kwargs : keyword arguments
Additional keyword arguments are passed to `matplotlib.ticker.EngFormatter`.
"""
def __init__(self, major: bool = True, **kwargs):
super().__init__(major=major)
self._formatter = mplticker.EngFormatter(**kwargs)
def _format_tick(self, x: float, pos: int | None = None) -> str:
# Delegate string conversion to the wrapped matplotlib formatter
return self._formatter(x, pos)
[docs]
class ChromaFormatter(mplticker.Formatter):
"""A formatter for chroma axes
Parameters
----------
key : str
The key in which to display pitch class labels.
See `core.midi_to_note` for supported values.
unicode : bool
If ``True``, use unicode symbols for accidentals.
If ``False``, use ASCII symbols for accidentals.
See Also
--------
matplotlib.ticker.Formatter
"""
key: str
unicode: bool
[docs]
def __init__(self, key: str = "C:maj", unicode: bool = True):
super().__init__()
self.key = key
self.unicode = unicode
def __call__(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Format for chroma positions"""
return core.midi_to_note(int(x), octave=False, cents=False, key=self.key, unicode=self.unicode)
[docs]
class ChromaSvaraFormatter(mplticker.Formatter):
"""A formatter for chroma axes with svara instead of notes.
If mela is given, Carnatic svara names will be used.
Otherwise, Hindustani svara names will be used.
If `Sa` is not given, it will default to 0 (equivalent to `C`).
Parameters
----------
Sa : float or None
The MIDI note number corresponding to Sa. If ``None``, defaults to 0 (C).
mela : str, int, or None
For Carnatic svara, the index or name of the melakarta raga.
If ``None``, Hindustani svara names are used.
abbr : bool
If ``True``, use abbreviated svara names.
If ``False``, use full svara names.
unicode : bool
If ``True``, use unicode symbols for accidentals.
If ``False``, use ASCII symbols for accidentals.
See Also
--------
ChromaFormatter
"""
Sa: float
mela: int | str | None
abbr: bool
unicode: bool
[docs]
def __init__(
self,
Sa: float | None = None,
mela: int | str | None = None,
abbr: bool = True,
unicode: bool = True,
):
super().__init__()
if Sa is None:
Sa = 0
self.Sa = Sa
self.mela = mela
self.abbr = abbr
self.unicode = unicode
def __call__(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Format for chroma positions"""
if self.mela is not None:
return core.midi_to_svara_c(
int(x),
Sa=self.Sa,
mela=self.mela,
octave=False,
abbr=self.abbr,
unicode=self.unicode,
)
else:
return core.midi_to_svara_h(int(x), Sa=self.Sa, octave=False, abbr=self.abbr, unicode=self.unicode)
[docs]
class ChromaFJSFormatter(mplticker.Formatter):
"""A formatter for chroma axes with functional just notation
Parameters
----------
intervals : str or array of float in [1, 2)
The interval specification for the chroma axis.
See `core.interval_frequencies` for supported values.
unison : str
The unison (tonic) note name.
unicode : bool
If ``True``, use unicode symbols for accidentals.
If ``False``, use ASCII symbols for accidentals.
bins_per_octave : int or None
The number of bins per octave. If ``None``, inferred from ``intervals``.
See Also
--------
matplotlib.ticker.Formatter
"""
unison: str
unicode: bool
intervals: str | Collection[float]
bins_per_octave: int
intervals_: np.ndarray[tuple[int], np.dtype[np.float64]]
[docs]
def __init__(
self,
*,
intervals: str | Collection[float],
unison: str = "C",
unicode: bool = True,
bins_per_octave: int | None = None,
):
super().__init__()
self.unison = unison
self.unicode = unicode
self.intervals = intervals
try:
if not isinstance(intervals, str):
bins_per_octave = len(intervals)
if not isinstance(bins_per_octave, int):
raise ParameterError(f"bins_per_octave={bins_per_octave} must be integer-valued")
self.bins_per_octave = bins_per_octave
# Construct the explicit interval set
self.intervals_ = core.interval_frequencies(
self.bins_per_octave,
fmin=1,
intervals=intervals,
bins_per_octave=self.bins_per_octave,
)
except TypeError as exc:
raise ParameterError(
f"intervals={intervals} must be of type str or a collection of numbers between 1 and 2"
) from exc
def __call__(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Format for chroma positions"""
lab: str = core.interval_to_fjs(
self.intervals_[int(x) % self.bins_per_octave],
unison=self.unison,
unicode=self.unicode,
)
return lab
[docs]
class TonnetzFormatter(mplticker.Formatter):
"""A formatter for tonnetz axes
See Also
--------
matplotlib.ticker.Formatter
"""
def __call__(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
# pos is required by matplotlib's Formatter.__call__ interface and is unused in this method
"""Format for tonnetz positions"""
return [r"5$_y$", r"5$_x$", r"m3$_y$", r"m3$_x$", r"M3$_y$", r"M3$_x$"][int(x)]
[docs]
class Transformf0(mtransforms.Transform):
"""A utility class to handle f0-displacement for waveform visualizations.
Parameters
----------
f0 : np.ndarray
Array of fundamental frequency values (in Hz), one per frame.
Values may be NaN for unvoiced frames.
sr : number > 0
Audio sampling rate, used with ``hop_length`` to compute time stamps.
hop_length : int > 0
Number of audio samples between successive f0 frames.
bins_per_octave : int > 0
Number of bins per octave used for the pitch axis.
norm : float
Normalization factor applied to the pitch axis.
offset : float
Time offset (in seconds) applied to the frame time stamps.
transpose : bool
If ``True``, the time axis is the second dimension instead of the first.
is_inverted : bool
If ``True``, apply the inverse of the f0-displacement transformation.
"""
f0_interp: scipy.interpolate.interp1d
norm: float
bins_per_octave: int
f0: np.ndarray
sr: float
hop_length: int
offset: float
transpose: bool
input_dims: int
output_dims: int
is_separable: bool
is_inverted: bool
[docs]
def __init__(
self,
f0: np.ndarray,
*,
sr: float = 22050,
hop_length: int = 512,
bins_per_octave: int = 12,
norm: float = 1,
offset: float = 0,
transpose: bool = False,
is_inverted: bool = False,
):
super().__init__(shorthand_name="Transformf0")
if not np.any(np.isfinite(f0)) or np.nanmin(f0) <= 0:
raise ParameterError("f0 must be strictly positive (or NaN) and contain at least one finite value")
times = offset + core.times_like(f0, sr=sr, hop_length=hop_length)
import scipy.interpolate
self.f0_interp = scipy.interpolate.interp1d(
times,
f0,
kind="previous",
copy=False,
bounds_error=False,
assume_sorted=True,
)
self.norm = norm
self.bins_per_octave = bins_per_octave
self.f0 = f0
self.sr = sr
self.hop_length = hop_length
self.offset = offset
self.transpose = transpose
self.input_dims = 2
self.output_dims = 2
self.is_separable = False
self.is_inverted = is_inverted
def transform_non_affine(self, values: ArrayLike) -> np.ndarray:
"""Apply the f0 displacement transformation to the given values.
Parameters
----------
values : np.ndarray
An array of shape (..., 2) containing time and sample values to be
transformed. The order of time and sample values is determined by
the `transpose` parameter of this class.
Returns
-------
output : np.ndarray
An array of the same shape as `values`, containing the transformed
time and sample values.
"""
values = np.asarray(values)
if self.transpose:
idx = (1, 0)
else:
idx = (0, 1)
times = values[:, idx[0]]
samples = values[:, idx[1]]
output = np.empty_like(values)
output[:, idx[0]] = times
if self.is_inverted:
output[:, idx[1]] = (np.log2(samples) - np.log2(self.f0_interp(times))) * self.norm * self.bins_per_octave
else:
output[:, idx[1]] = 2.0 ** (samples / self.norm / self.bins_per_octave) * self.f0_interp(times)
return output
def inverted(self) -> Transformf0:
"""Return the inverse of this transformation.
Returns
-------
Transformf0
A new ``Transformf0`` with ``is_inverted`` toggled.
"""
return Transformf0(
f0=self.f0,
sr=self.sr,
hop_length=self.hop_length,
bins_per_octave=self.bins_per_octave,
norm=self.norm,
offset=self.offset,
transpose=self.transpose,
is_inverted=not self.is_inverted,
)
[docs]
def infer_cmap(
data: np.ndarray,
*,
robust: bool = True,
cmap_seq: str | colors.Colormap = "magma",
cmap_bool: str | colors.Colormap = "gray_r",
cmap_div: str | colors.Colormap = "coolwarm",
div_thresh: float = 0.0,
) -> Colormap:
"""Get a default colormap from the given data.
If the data is boolean, use a black and white colormap.
If the data has both positive and negative values,
use a diverging colormap.
Otherwise, use a sequential colormap.
Parameters
----------
data : np.ndarray
Input data
robust : bool
If True, discard the top and bottom 2% of data when calculating
range.
cmap_seq : str or matplotlib.colors.Colormap
The sequential colormap
cmap_bool : str or matplotlib.colors.Colormap
The boolean colormap
cmap_div : str or matplotlib.colors.Colormap
The diverging colormap
div_thresh : float
The threshold for determining whether to use a diverging colormap.
If the data has values both above and below this threshold, then
a diverging colormap is used.
Returns
-------
cmap : matplotlib.colors.Colormap
The colormap to use for ``data``
See Also
--------
matplotlib.pyplot.colormaps
"""
data = np.atleast_1d(data)
if not isinstance(cmap_seq, colors.Colormap):
cmap_seq = mcm[cmap_seq]
if not isinstance(cmap_bool, colors.Colormap):
cmap_bool = mcm[cmap_bool]
if not isinstance(cmap_div, colors.Colormap):
cmap_div = mcm[cmap_div]
if data.dtype.kind == "b":
return cmap_bool
data = data[np.isfinite(data)]
if robust:
min_p, max_p = 2, 98
else:
min_p, max_p = 0, 100
min_val, max_val = np.percentile(data, [min_p, max_p])
if min_val >= div_thresh or max_val <= div_thresh:
return cmap_seq
return cmap_div
# Deprecation rename of cmap -> infer_cmap for 1.0
cmap = moved(moved_from="librosa.display.cmap", version="1.0", version_removed="1.1")(infer_cmap)
_chroma_ax_types = (
"chroma",
"chroma_h",
"chroma_c",
"chroma_fjs",
)
_cqt_ax_types = (
"cqt_hz",
"cqt_note",
"cqt_svara",
"cqt_oct3",
)
_vqt_ax_types = (
"vqt_hz",
"vqt_note",
"vqt_oct3",
"vqt_fjs",
)
_freq_ax_types = (
"linear",
"fft",
"hz",
"fft_note",
"fft_svara",
"oct3",
)
_time_ax_types = (
"time",
"h",
"m",
"s",
"ms",
)
_lag_ax_types = (
"lag",
"lag_h",
"lag_m",
"lag_s",
"lag_ms",
)
_misc_ax_types = (
"tempo",
"fourier_tempo",
"mel",
"mel_oct3",
"log",
"tonnetz",
"frames",
)
_AXIS_COMPAT = set(
[(t, t) for t in _misc_ax_types]
+ [t for t in product(_chroma_ax_types, _chroma_ax_types)]
+ [t for t in product(_cqt_ax_types, _cqt_ax_types)]
+ [t for t in product(_vqt_ax_types, _vqt_ax_types)]
+ [t for t in product(_freq_ax_types, _freq_ax_types)]
+ [t for t in product(_time_ax_types, _time_ax_types)]
+ [t for t in product(_lag_ax_types, _lag_ax_types)]
)
def _check_axes(axes: mplaxes.Axes | None) -> mplaxes.Axes:
"""Check if "axes" is an instance of an axis object. If not, use `gca`."""
if axes is None:
axes = plt.gca()
elif not isinstance(axes, mplaxes.Axes):
raise ParameterError(
"`axes` must be an instance of matplotlib.axes.Axes. Found type(axes)={}".format(type(axes))
)
return axes
def _scale_axes(axes, ax_type, which, tempo_min, tempo_max):
"""Set the axis scaling"""
kwargs = dict()
thresh = "linthresh"
base = "base"
scale = "linscale"
if which == "x":
scaler = axes.set_xscale
limit = axes.set_xlim
else:
scaler = axes.set_yscale
limit = axes.set_ylim
# Map ticker scales
if ax_type in ["mel", "mel_oct3"]:
mode = "symlog"
kwargs[thresh] = 1000.0
kwargs[base] = 2
elif ax_type in [
"cqt",
"cqt_hz",
"cqt_note",
"cqt_svara",
"cqt_oct3",
"vqt_hz",
"vqt_note",
"vqt_fjs",
"vqt_oct3",
]:
mode = "log"
kwargs[base] = 2
elif ax_type in ["log", "fft_note", "fft_svara", "log_oct3"]:
mode = "symlog"
kwargs[base] = 2
kwargs[thresh] = float(core.note_to_hz("C2"))
kwargs[scale] = 0.5
elif ax_type in ["tempo", "fourier_tempo"]:
mode = "log"
kwargs[base] = 2
limit(tempo_min, tempo_max)
else:
return
scaler(mode, **kwargs)
def _decorate_axis(
axis,
ax_type,
key="C:maj",
Sa=None,
mela=None,
thaat=None,
unicode=True,
fmin=None,
unison=None,
intervals=None,
bins_per_octave=None,
n_bins=None,
):
"""Configure axis tickers, locators, and labels"""
time_units = {"h": "hours", "m": "minutes", "s": "seconds", "ms": "milliseconds"}
if ax_type == "tonnetz":
axis.set_major_formatter(TonnetzFormatter())
axis.set_major_locator(mplticker.FixedLocator([0, 1, 2, 3, 4, 5]))
axis.set_label_text("Tonnetz")
elif ax_type == "chroma":
axis.set_major_formatter(ChromaFormatter(key=key, unicode=unicode))
degrees = core.key_to_degrees(key)
axis.set_major_locator(
mplticker.FixedLocator(
cast(
"Sequence[float]",
np.add.outer(12 * np.arange(10), degrees, dtype=float).ravel(),
)
)
)
axis.set_label_text("Pitch class")
elif ax_type == "chroma_h":
if Sa is None:
Sa = 0
axis.set_major_formatter(ChromaSvaraFormatter(Sa=Sa, unicode=unicode))
if thaat is None:
# If no thaat is given, show all svara
degrees = np.arange(12)
else:
degrees = core.thaat_to_degrees(thaat)
# Rotate degrees relative to Sa
degrees = np.mod(degrees + Sa, 12)
axis.set_major_locator(
mplticker.FixedLocator(
cast(
"Sequence[float]",
np.add.outer(12 * np.arange(10), degrees, dtype=float).ravel(),
)
)
)
axis.set_label_text("Svara")
elif ax_type == "chroma_c":
if Sa is None:
Sa = 0
axis.set_major_formatter(ChromaSvaraFormatter(Sa=Sa, mela=mela, unicode=unicode))
degrees = core.mela_to_degrees(mela)
# Rotate degrees relative to Sa
degrees = np.mod(degrees + Sa, 12)
axis.set_major_locator(
mplticker.FixedLocator(
cast(
"Sequence[float]",
np.add.outer(12 * np.arange(10), degrees, dtype=float).ravel(),
)
)
)
axis.set_label_text("Svara")
elif ax_type == "chroma_fjs":
if fmin is None:
fmin = core.note_to_hz("C1")
if unison is None:
unison = core.hz_to_note(fmin, octave=False, cents=False)
axis.set_major_formatter(
ChromaFJSFormatter(
intervals=intervals,
unison=unison,
unicode=unicode,
bins_per_octave=bins_per_octave,
)
)
if isinstance(intervals, str) and bins_per_octave > 7:
# If intervals are implicit, generate the first 7 and identify
# them in the sorted set
tick_intervals = core.interval_frequencies(
7,
fmin=1,
intervals=intervals,
bins_per_octave=bins_per_octave,
sort=False,
)
all_intervals = core.interval_frequencies(
bins_per_octave,
fmin=1,
intervals=intervals,
bins_per_octave=bins_per_octave,
sort=True,
)
degrees = util.match_events(tick_intervals, all_intervals)
else:
# If intervals are explicit, tick them all
degrees = np.arange(bins_per_octave)
axis.set_major_locator(mplticker.FixedLocator(degrees)) # type: ignore[arg-type]
axis.set_label_text("Pitch class")
elif ax_type in ["tempo", "fourier_tempo"]:
axis.set_major_formatter(mplticker.ScalarFormatter())
axis.set_major_locator(mplticker.LogLocator(base=2.0))
axis.set_label_text("BPM")
elif ax_type == "time":
axis.set_major_formatter(TimeFormatter(unit=None, lag=False))
axis.set_major_locator(mplticker.MaxNLocator(prune=None, steps=[1, 1.5, 5, 6, 10]))
axis.set_label_text("Time")
elif ax_type in time_units:
axis.set_major_formatter(TimeFormatter(unit=ax_type, lag=False))
axis.set_major_locator(mplticker.MaxNLocator(prune=None, steps=[1, 1.5, 5, 6, 10]))
axis.set_label_text("Time ({:s})".format(time_units[ax_type]))
elif ax_type == "lag":
axis.set_major_formatter(TimeFormatter(unit=None, lag=True))
axis.set_major_locator(mplticker.MaxNLocator(prune=None, steps=[1, 1.5, 5, 6, 10]))
axis.set_label_text("Lag")
elif isinstance(ax_type, str) and ax_type.startswith("lag_"):
unit = ax_type[4:]
axis.set_major_formatter(TimeFormatter(unit=unit, lag=True))
axis.set_major_locator(mplticker.MaxNLocator(prune=None, steps=[1, 1.5, 5, 6, 10]))
axis.set_label_text("Lag ({:s})".format(time_units[unit]))
elif ax_type == "cqt_note":
axis.set_major_formatter(NoteFormatter(key=key, unicode=unicode))
# Where is C1 relative to 2**k hz?
log_C1 = np.log2(core.note_to_hz("C1"))
C_offset = 2.0 ** (log_C1 - np.floor(log_C1))
axis.set_major_locator(mplticker.LogLocator(base=2.0, subs=(C_offset,)))
axis.set_minor_formatter(NoteFormatter(key=key, major=False, unicode=unicode))
axis.set_minor_locator(mplticker.LogLocator(base=2.0, subs=C_offset * 2.0 ** (np.arange(1, 12) / 12.0)))
axis.set_label_text("Note")
elif ax_type == "cqt_svara":
axis.set_major_formatter(SvaraFormatter(Sa=Sa, mela=mela, unicode=unicode))
# Find the offset of Sa relative to 2**k Hz
sa_offset = 2.0 ** (np.log2(Sa) - np.floor(np.log2(Sa)))
axis.set_major_locator(mplticker.LogLocator(base=2.0, subs=(sa_offset,)))
axis.set_minor_formatter(SvaraFormatter(Sa=Sa, mela=mela, major=False, unicode=unicode))
axis.set_minor_locator(mplticker.LogLocator(base=2.0, subs=sa_offset * 2.0 ** (np.arange(1, 12) / 12.0)))
axis.set_label_text("Svara")
elif ax_type == "vqt_fjs":
if fmin is None:
fmin = float(core.note_to_hz("C1"))
axis.set_major_formatter(
FJSFormatter(
intervals=intervals,
fmin=fmin,
unison=unison,
unicode=unicode,
bins_per_octave=bins_per_octave,
n_bins=n_bins,
)
)
log_fmin = np.log2(fmin)
fmin_offset = 2.0 ** (log_fmin - np.floor(log_fmin))
axis.set_major_locator(mplticker.LogLocator(base=2.0, subs=(fmin_offset,)))
axis.set_minor_formatter(
FJSFormatter(
intervals=intervals,
fmin=fmin,
unison=unison,
unicode=unicode,
bins_per_octave=bins_per_octave,
n_bins=n_bins,
major=False,
)
)
axis.set_minor_locator(
mplticker.FixedLocator(
core.interval_frequencies(
n_bins * 12 // bins_per_octave,
fmin=fmin,
intervals=intervals,
bins_per_octave=12,
) # type: ignore[arg-type]
)
)
axis.set_label_text("Note")
elif ax_type == "vqt_hz":
if fmin is None:
fmin = core.note_to_hz("C1")
axis.set_major_formatter(LogHzFormatter())
log_fmin = np.log2(fmin)
fmin_offset = 2.0 ** (log_fmin - np.floor(log_fmin))
axis.set_major_locator(mplticker.LogLocator(base=2.0, subs=(fmin_offset,)))
axis.set_minor_formatter(LogHzFormatter(major=False))
axis.set_minor_locator(
mplticker.LogLocator(
base=2.0,
subs=core.interval_frequencies(12, fmin=fmin_offset, intervals=intervals, bins_per_octave=12), # type: ignore[arg-type]
)
)
axis.set_label_text("Hz")
elif ax_type == "vqt_note":
if fmin is None:
fmin = core.note_to_hz("C1")
axis.set_major_formatter(NoteFormatter(key=key, unicode=unicode))
log_fmin = np.log2(fmin)
fmin_offset = 2.0 ** (log_fmin - np.floor(log_fmin))
axis.set_major_locator(mplticker.LogLocator(base=2.0, subs=(fmin_offset,)))
axis.set_minor_formatter(NoteFormatter(key=key, unicode=unicode, major=False))
axis.set_minor_locator(
mplticker.LogLocator(
base=2.0,
subs=core.interval_frequencies(12, fmin=fmin_offset, intervals=intervals, bins_per_octave=12), # type: ignore[arg-type]
)
)
axis.set_label_text("Note")
elif ax_type in ["cqt_hz"]:
axis.set_major_formatter(LogHzFormatter())
log_C1 = np.log2(core.note_to_hz("C1"))
C_offset = 2.0 ** (log_C1 - np.floor(log_C1))
axis.set_major_locator(mplticker.LogLocator(base=2.0, subs=(C_offset,)))
axis.set_major_locator(mplticker.LogLocator(base=2.0))
axis.set_minor_formatter(LogHzFormatter(major=False))
axis.set_minor_locator(mplticker.LogLocator(base=2.0, subs=C_offset * 2.0 ** (np.arange(1, 12) / 12.0)))
axis.set_label_text("Hz")
elif ax_type == "fft_note":
axis.set_major_formatter(NoteFormatter(key=key, unicode=unicode))
# Where is C1 relative to 2**k hz?
log_C1 = np.log2(core.note_to_hz("C1"))
C_offset = 2.0 ** (log_C1 - np.floor(log_C1))
axis.set_major_locator(mplticker.SymmetricalLogLocator(axis.get_transform()))
axis.set_minor_formatter(NoteFormatter(key=key, major=False, unicode=unicode))
axis.set_minor_locator(
mplticker.LogLocator(base=2.0, subs=2.0 ** (np.arange(1, 12) / 12.0)) # type: ignore[arg-type]
)
axis.set_label_text("Note")
elif ax_type == "fft_svara":
axis.set_major_formatter(SvaraFormatter(Sa=Sa, mela=mela, unicode=unicode))
# Find the offset of Sa relative to 2**k Hz
log_Sa = np.log2(Sa)
sa_offset = 2.0 ** (log_Sa - np.floor(log_Sa))
axis.set_major_locator(mplticker.SymmetricalLogLocator(axis.get_transform(), base=2.0, subs=[sa_offset]))
axis.set_minor_formatter(SvaraFormatter(Sa=Sa, mela=mela, major=False, unicode=unicode))
axis.set_minor_locator(mplticker.LogLocator(base=2.0, subs=sa_offset * 2.0 ** (np.arange(1, 12) / 12.0)))
axis.set_label_text("Svara")
elif ax_type in ["mel", "log"]:
axis.set_major_formatter(mplticker.ScalarFormatter())
axis.set_major_locator(mplticker.SymmetricalLogLocator(axis.get_transform()))
axis.set_label_text("Hz")
elif ax_type in ["linear", "hz", "fft"]:
axis.set_major_formatter(mplticker.ScalarFormatter())
axis.set_label_text("Hz")
elif ax_type in ["oct3", "cqt_oct3", "vqt_oct3", "log_oct3", "mel_oct3"]:
# Label once per octave
if ax_type == "mel_oct3":
# Suppress major ticks for frequencies below 100 Hz in mel mode
axis.set_major_locator(mplticker.FixedLocator(_OCT3_FREQUENCIES[5::3])) # type: ignore[arg-type]
else:
axis.set_major_locator(mplticker.FixedLocator(_OCT3_FREQUENCIES[::3])) # type: ignore[arg-type]
axis.set_major_formatter(AdaptiveEngFormatter(major=True, unit="Hz"))
axis.set_label_text("Frequency")
# Minor ticks at the 1/3 octaves
axis.set_minor_locator(mplticker.FixedLocator(_OCT3_FREQUENCIES, nbins=None)) # type: ignore[arg-type]
axis.set_minor_formatter(AdaptiveEngFormatter(major=False, unit="Hz"))
elif ax_type in ["frames"]:
axis.set_label_text("Frames")
elif ax_type in ["off", "none", None]:
axis.set_label_text("")
axis.set_ticks([])
else:
raise ParameterError(f"Unsupported axis type: {ax_type}")
def _coord_fft_hz(n: int, sr: float = 22050, n_fft: int | None = None, **_kwargs: Any) -> _Array1D[np.float64]:
"""Get the frequencies for FFT bins"""
if n_fft is None:
n_fft = 2 * (n - 1)
# The following code centers the FFT bins at their frequencies
# and clips to the non-negative frequency range [0, nyquist]
basis = core.fft_frequencies(sr=sr, n_fft=n_fft)
return basis
def _coord_mel_hz(
n: int,
fmin: float | None = 0.0,
fmax: float | None = None,
sr: float = 22050,
htk: bool = False,
**_kwargs: Any,
) -> _Array1D[np.float64]:
"""Get the frequencies for Mel bins"""
if fmin is None:
fmin = 0.0
if fmax is None:
fmax = 0.5 * sr
basis = core.mel_frequencies(n, fmin=fmin, fmax=fmax, htk=htk)
return basis
def _coord_cqt_hz(
n: int,
fmin: _FloatLike_co | None = None,
bins_per_octave: int = 12,
sr: float = 22050,
**_kwargs: Any,
) -> _Array1D[np.float64]:
"""Get CQT bin frequencies"""
if fmin is None:
fmin = core.note_to_hz("C1")
# Apply tuning correction
fmin = fmin * 2.0 ** (_kwargs.get("tuning", 0.0) / bins_per_octave)
# we drop by half a bin so that CQT bins are centered vertically
freqs = core.cqt_frequencies(
n,
fmin=fmin,
bins_per_octave=bins_per_octave,
)
if np.any(freqs > 0.5 * sr):
warnings.warn(
"Frequency axis exceeds Nyquist. Did you remember to set all spectrogram parameters in specshow?",
stacklevel=4,
)
return freqs
def _coord_vqt_hz(
n: int,
fmin: _FloatLike_co | None = None,
bins_per_octave: int = 12,
sr: float = 22050,
intervals: str | Collection[float] | None = None,
unison: str | None = None, # noqa: ARG001
**_kwargs: Any,
) -> _Array1D[np.float64]:
if fmin is None:
fmin = core.note_to_hz("C1")
if intervals is None:
raise ParameterError("VQT axis coordinates cannot be defined without intervals")
freqs = core.interval_frequencies(n, fmin=fmin, intervals=intervals, bins_per_octave=bins_per_octave)
if np.any(freqs > 0.5 * sr):
warnings.warn(
"Frequency axis exceeds Nyquist. Did you remember to set all spectrogram parameters in specshow?",
stacklevel=4,
)
return freqs
def _coord_chroma(n: int, bins_per_octave: int = 12, **_kwargs: Any) -> np.ndarray:
"""Get chroma bin numbers"""
return np.linspace(0, (12.0 * n) / bins_per_octave, num=n, endpoint=False)
def _coord_tempo(n: int, sr: float = 22050, hop_length: int = 512, **_kwargs: Any) -> np.ndarray:
"""Tempo coordinates"""
basis = core.tempo_frequencies(n + 1, sr=sr, hop_length=hop_length)[1:]
return basis
def _coord_fourier_tempo(
n: int,
sr: float = 22050,
hop_length: int = 512,
win_length: int | None = None,
**_kwargs: Any,
) -> np.ndarray:
"""Fourier tempogram coordinates"""
if win_length is None:
win_length = 2 * (n - 1)
# The following code centers the FFT bins at their frequencies
# and clips to the non-negative frequency range [0, nyquist]
basis = core.fourier_tempo_frequencies(sr=sr, hop_length=hop_length, win_length=win_length)
return basis
def _coord_n(n: int, **_kwargs: Any) -> np.ndarray:
"""Get bare positions"""
return np.arange(n)
def _coord_time(n: int, sr: float = 22050, hop_length: int = 512, **_kwargs: Any) -> np.ndarray:
"""Get time coordinates from frames"""
times: np.ndarray = core.frames_to_time(np.arange(n), sr=sr, hop_length=hop_length)
return times
def _same_axes(x_axis, y_axis, xlim, ylim):
"""Check if two axes are similar, used to determine squared plots"""
axes_compatible_and_not_none = (x_axis, y_axis) in _AXIS_COMPAT
axes_same_lim = xlim == ylim
return axes_compatible_and_not_none and axes_same_lim
def _radian_formatter(x, pos): # noqa: ARG001
# pos is required by matplotlib's FuncFormatter calling convention and is unused here
"""Format a tick value (in radians) as a rational multiple of pi"""
m = x / np.pi
# hard to imagine going finer than pi/16 (11°)
frac = Fraction(m).limit_denominator(16)
num, den = frac.numerator, frac.denominator
if num == 0:
return " 0"
sign = "-" if num * den < 0 else " "
num_abs = abs(num)
# Build numerator string
coeff = "" if num_abs == 1 else str(num_abs)
if den == 1:
return f"{sign}{coeff}π"
else:
return f"{sign}{coeff}π/{den}"
[docs]
def colorbar_phase(
im: matplotlib.cm.ScalarMappable,
*,
numticks: int = 9,
ax: matplotlib.axes.Axes | None = None,
fig: matplotlib.figure.FigureBase | None = None,
**kwargs: Any,
) -> matplotlib.colorbar.Colorbar:
"""Attach a colorbar to an image representing phase data in radians.
The colorbar will display ticks at rational multiples of π.
Parameters
----------
im : matplotlib.cm.ScalarMappable
The image to which the colorbar will be attached.
Generally this will be a `matplotlib.image.AxesImage` or `matplotlib.collections.QuadMesh`
as returned by `specshow`.
numticks : int > 0
The number of ticks to display on the colorbar.
Default is 9, corresponding to multiples of π/4.
ax : matplotlib.axes.Axes or None
The axes to which the colorbar will be attached.
If None, the colorbar will be attached to the axes of `im`.
fig : matplotlib.figure.Figure, SubFigure, or None
The figure to which the colorbar will be attached.
If None, the colorbar will be attached to the figure of `im`.
**kwargs
Additional keyword arguments to pass to `fig.colorbar`.
Returns
-------
cbar : matplotlib.colorbar.Colorbar
The created colorbar object.
See Also
--------
specshow
colorbar_db
matplotlib.colorbar.Colorbar
Examples
--------
Attach a colorbar to a phase spectrogram
>>> import matplotlib.pyplot as plt
>>> import librosa
>>> y, sr = librosa.loadx('trumpet')
>>> S = librosa.stft(y)
>>> fig, ax = plt.subplots()
>>> im = librosa.display.specshow(S, ax=ax, y_axis='log', x_axis='time', vscale='phase')
>>> librosa.display.colorbar_phase(im)
>>> plt.show()
Attach a colorbar to one subplot axes, and show as multiples of π/3.
>>> fig, ax = plt.subplots(nrows=2, sharex=True, sharey=True)
>>> im_mag = librosa.display.specshow(S, ax=ax[0], y_axis='log', x_axis='time', vscale='dBFS')
>>> cbar = librosa.display.colorbar_db(im_mag, ax=ax[0], label='dBFS')
>>> im_ph = librosa.display.specshow(S, ax=ax[1], y_axis='log', x_axis='time', vscale='dphase')
>>> cbar = librosa.display.colorbar_phase(im_ph, ax=ax[1], numticks=7)
>>> ax[0].label_outer()
>>> plt.show()
"""
if fig is None:
fig = im.figure
if ax is None:
ax = im.axes
kwargs.setdefault("label", "radians")
kwargs.setdefault("ticks", mplticker.LinearLocator(numticks=numticks))
kwargs.setdefault("format", mplticker.FuncFormatter(_radian_formatter))
cbar = fig.colorbar(
im,
ax=ax,
**kwargs,
)
return cbar
[docs]
def colorbar_db(
im: matplotlib.cm.ScalarMappable,
*,
ax: matplotlib.axes.Axes | None = None,
fig: matplotlib.figure.FigureBase | None = None,
format: str | mplticker.Formatter = "% -3.f",
**kwargs: Any,
) -> matplotlib.colorbar.Colorbar:
"""Attach a colorbar to an image representing decibel-scaled data.
Parameters
----------
im : matplotlib.cm.ScalarMappable
The image to which the colorbar will be attached.
Generally this will be a `matplotlib.image.AxesImage` or `matplotlib.collections.QuadMesh`
as returned by `specshow`.
ax : matplotlib.axes.Axes or None
The axes to which the colorbar will be attached.
If None, the colorbar will be attached to the axes of `im`.
fig : matplotlib.figure.Figure, SubFigure, or None
The figure to which the colorbar will be attached.
If None, the colorbar will be attached to the figure of `im`.
format : str
The format string for the colorbar ticks.
Default is "% -3.f", which displays integer values.
You can change this to a different format if needed.
**kwargs
Additional keyword arguments to pass to `fig.colorbar`.
Returns
-------
cbar : matplotlib.colorbar.Colorbar
The created colorbar object.
See Also
--------
specshow
colorbar_phase
matplotlib.colorbar.Colorbar
Examples
--------
Attach a colorbar to a magnitude spectrogram
>>> import matplotlib.pyplot as plt
>>> import librosa
>>> y, sr = librosa.loadx('trumpet')
>>> S = librosa.stft(y)
>>> fig, ax = plt.subplots()
>>> im = librosa.display.specshow(S, ax=ax, y_axis='log', x_axis='time', vscale='dB')
>>> librosa.display.colorbar_db(im)
>>> plt.show()
Attach a colorbar to one subplot axes. We can also set a label for the colorbar.
>>> fig, ax = plt.subplots(nrows=2, sharex=True, sharey=True)
>>> im_mag = librosa.display.specshow(S, ax=ax[0], y_axis='log', x_axis='time', vscale='dBFS')
>>> cbar = librosa.display.colorbar_db(im_mag, ax=ax[0], label='dBFS')
>>> im_ph = librosa.display.specshow(S, ax=ax[1], y_axis='log', x_axis='time', vscale='dphase')
>>> cbar = librosa.display.colorbar_phase(im_ph, ax=ax[1])
>>> ax[0].label_outer()
>>> plt.show()
"""
if fig is None:
fig = im.figure
if ax is None:
ax = im.axes
kwargs.setdefault("label", "dB")
cbar = fig.colorbar(
im,
ax=ax,
format=format,
**kwargs,
)
return cbar