Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/source/coreapi_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,33 @@ Modifying the sample rate will not access the data, but the data will be resampl
ad.sample_rate = 48_000 # Resample the signal at 48 kHz. Nothing happens yet
resampled_signal = ad.get_value() # The original audio data will be resampled while being fetched here.

Plotting
""""""""

``AudioData`` waveforms can be plotted thanks to the :meth:`osekit.core.audio_data.AudioData.plot` method.

The waveform is plotted thanks to the `matplotlib.axes.Axes.plot() method <https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.plot.html>`_.
Every keyword argument passed to the :meth:`osekit.core.audio_data.AudioData.plot` method will be passed through to
the `matplotlib.axes.Axes.plot() method <https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.plot.html>`_:

.. code-block:: python

from osekit.core.audio_data import AudioData
import matplotlib.pyplot as plt

ad = AudioData(...)
ad_signal = ad.get_value()

fig, ax = plt.subplots()

ad.plot(
values=ad_signal, # If not provided, the signal will be fetched
ax=ax, # If not provided, default figure and axes will be created
linestyle="dashdot", # Additional kwargs passed to pyplot Plot()
linewidth=2, # Additional kwargs passed to pyplot Plot()
)

plt.show()

Audio Dataset
^^^^^^^^^^^^^
Expand Down
27 changes: 27 additions & 0 deletions docs/source/example_reshaping_one_file.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,33 @@
")"
]
},
{
"metadata": {},
"cell_type": "markdown",
"source": "It can be plotted:",
"id": "26fd8c7ac700850b"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"import matplotlib.pyplot as plt\n",
"import matplotlib.dates as mdates\n",
"\n",
"_, ax = plt.subplots()\n",
"\n",
"audio_data.plot(ax=ax)\n",
"\n",
"ax.set_ylabel(\"Amplitude\")\n",
"ax.set_xlabel(\"Time (%M:%S\")\n",
"ax.xaxis.set_major_formatter(mdates.DateFormatter(\"%M:%S\"))\n",
"\n",
"plt.show()"
],
"id": "bc62c794690f4555"
},
{
"cell_type": "markdown",
"id": "60f887eb643ce9c9",
Expand Down
99 changes: 49 additions & 50 deletions docs/source/example_spectrogram.ipynb

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/osekit/core/audio_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
from typing import TYPE_CHECKING, Self

import numpy as np
import pandas as pd
import soundfile as sf
import soxr
from matplotlib import pyplot as plt
from pandas import Timedelta, Timestamp

from osekit.config import resample_quality_settings
Expand All @@ -21,6 +23,7 @@
from osekit.core.base_data import BaseData
from osekit.core.instrument import Instrument
from osekit.utils.audio import Butterworth, Normalization, normalize
from osekit.utils.plot import get_default_axes

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -372,6 +375,34 @@ def get_value_calibrated(self) -> np.ndarray:
)
return raw_data * calibration_factor

def plot(
self,
ax: plt.Axes | None = None,
values: np.ndarray | None = None,
**kwargs, # noqa: ANN003
) -> None:
"""Plot the waveform on a specific ``Axes``.

Parameters
----------
ax: plt.axes | None
``Axes`` on which the waveform should be plotted.
Defaulted to ``osekit.utils.plot.get_default_axes()``.
values: np.ndarray | None
Values of the audio data. Will be fetched if ``None``.
kwargs
Keyword arguments that are passed
to the ``matplotlib.axes._axes.Axes.plot()`` method.

"""
ax = ax if ax is not None else get_default_axes()
values = self.get_value() if values is None else values

time = pd.date_range(start=self.begin, end=self.end, periods=values.shape[0])

ax.xaxis_date()
ax.plot(time, values, **kwargs)

def write(
self,
folder: Path,
Expand Down
44 changes: 3 additions & 41 deletions src/osekit/core/spectro_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from osekit.core.base_data import BaseData, TFile
from osekit.core.spectro_file import SpectroFile
from osekit.core.spectro_item import SpectroItem
from osekit.utils.plot import get_default_axes

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -98,45 +99,6 @@ def __init__(
self.previous_data = None
self.next_data = None

@staticmethod
def get_default_ax() -> plt.Axes:
"""Return a default-formatted ``Axes`` on a new figure.

The default osekit spectrograms are plotted on wide, borderless spectrograms.
This method set the default figure and axes parameters.

Returns
-------
plt.Axes:
The default ``Axes`` on a new figure.

"""
# Legacy OSEkit behaviour.
_, ax = plt.subplots(
nrows=1,
ncols=1,
figsize=(1813 / 100, 512 / 100),
dpi=100,
)

ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
ax.set_frame_on(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines["bottom"].set_visible(False)
ax.spines["top"].set_visible(False)
plt.axis("off")
plt.subplots_adjust(
top=1,
bottom=0,
right=1,
left=0,
hspace=0,
wspace=0,
)
return ax

@BaseData.end.setter
def end(self, end: Timestamp | None) -> None:
"""Trim the end timestamp of the data.
Expand Down Expand Up @@ -449,14 +411,14 @@ def plot(
----------
ax: plt.axes | None
``Axes`` on which the spectrogram should be plotted.
Defaulted to ``SpectroData.get_default_ax()``.
Defaulted to ``osekit.utils.plot.get_default_axes()``.
sx: np.ndarray | None
Spectrogram ``sx`` values. Will be computed if ``None``.
scale: osekit.core.frequecy_scale.Scale
Custom frequency scale to use for plotting the spectrogram.

"""
ax = ax if ax is not None else SpectroData.get_default_ax()
ax = ax if ax is not None else get_default_axes()
sx = self.get_value() if sx is None else sx

sx = self._to_db(sx)
Expand Down
40 changes: 40 additions & 0 deletions src/osekit/utils/plot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from matplotlib import pyplot as plt


def get_default_axes() -> plt.Axes:
"""Return a default-formatted ``Axes`` on a new figure.

By default, OSEkit plots on wide, borderless figures.
This method set the default figure and axes parameters.

Returns
-------
plt.Axes:
The default ``Axes`` on a new figure.

"""
# Legacy OSEkit behaviour.
_, ax = plt.subplots(
nrows=1,
ncols=1,
figsize=(1813 / 100, 512 / 100),
dpi=100,
)

ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
ax.set_frame_on(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines["bottom"].set_visible(False)
ax.spines["top"].set_visible(False)
plt.axis("off")
plt.subplots_adjust(
top=1,
bottom=0,
right=1,
left=0,
hspace=0,
wspace=0,
)
return ax
73 changes: 73 additions & 0 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import importlib
import logging
from collections.abc import Generator
from pathlib import Path
from typing import Any, Literal

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pytest
import soundfile as sf
from matplotlib.axes import Axes
from pandas import Timedelta, Timestamp
from scipy import signal

Expand All @@ -32,6 +35,7 @@
generate_sample_audio,
normalize,
)
from osekit.utils.plot import get_default_axes
from tests.helpers.audio import MockedAudioData


Expand Down Expand Up @@ -2227,3 +2231,72 @@ def test_butter_audiodataset() -> None:
for ad in ads.data:
ad.butter = butter2
assert ads.butter == butter2


plot_calls = []


@pytest.fixture(autouse=False)
def patch_plot(monkeypatch: pytest.MonkeyPatch) -> Generator[None, Any, None]:
def mock_plot(self: Axes, *args: Any, **kwargs: Any) -> None:
plot_calls.append((self, kwargs))

monkeypatch.setattr(plt.Axes, "plot", mock_plot)
yield
plot_calls.clear()


def test_plot_on_default_axes(patch_plot: None) -> None:
ad = MockedAudioData(mocked_value=[1, 2, 3])

default_axes = get_default_axes()
ad.plot()
axes, _ = plot_calls.pop()

assert np.array_equal(axes.viewLim, default_axes.viewLim)
assert np.array_equal(axes.dataLim, default_axes.dataLim)
assert np.array_equal(axes.spines, default_axes.spines)


def test_plot_on_custom_axes(patch_plot: None) -> None:
ad = MockedAudioData(mocked_value=[1, 2, 3])

_, custom_axes = plt.subplots()
ad.plot(ax=custom_axes)
used_axes, _ = plot_calls.pop()

assert custom_axes is used_axes


def test_plot_with_kwargs(patch_plot: None) -> None:
ad = MockedAudioData(mocked_value=[1, 2, 3])

ad.plot(None, None, velvet="underground", sweet="jane")
_, kwargs = plot_calls.pop()
assert np.array_equal(kwargs, {"velvet": "underground", "sweet": "jane"})


def test_plot_with_value(patch_plot: None, monkeypatch: pytest.Monke) -> None:
get_value_calls = [0]

get_value_method = MockedAudioData.get_value

def mocked_get_value(*args: Any, **kwargs: Any) -> np.ndarray:
get_value_calls[0] += 1
return get_value_method(*args, **kwargs)

monkeypatch.setattr(MockedAudioData, "get_value", mocked_get_value)

assert get_value_calls[0] == 0

ad = MockedAudioData(mocked_value=[1, 2, 3])
vs = ad.get_value()

assert get_value_calls[0] == 1

ad.plot(values=vs, the="voidz")
_, kwargs = plot_calls.pop()

# Values are provided and shouldn't be fetched again
assert get_value_calls[0] == 1
assert np.array_equal(kwargs, {"the": "voidz"})