diff --git a/src/osekit/core/audio_data.py b/src/osekit/core/audio_data.py index 806f8e797..49dbd1767 100644 --- a/src/osekit/core/audio_data.py +++ b/src/osekit/core/audio_data.py @@ -120,15 +120,7 @@ def normalization_values(self) -> dict: @normalization_values.setter def normalization_values(self, value: dict | None) -> None: - self._normalization_values = ( - value - if value - else { - "mean": None, - "peak": None, - "std": None, - } - ) + self._normalization_values = value or {"mean": None, "peak": None, "std": None} @property def butter(self) -> Butterworth: @@ -165,15 +157,13 @@ def _make_item( return AudioItem(file=file, begin=begin, end=end) @classmethod - def _make_file(cls, path: Path, begin: Timestamp) -> AudioFile: + def _make_file(cls, file_dict: dict) -> AudioFile: """Make an ``AudioFile`` from a path and a begin timestamp. Parameters ---------- - path: Path - Path to the file. - begin: Timestamp - Begin of the file. + file_dict: dict + Serialized AudioFile. Returns ------- @@ -181,7 +171,7 @@ def _make_file(cls, path: Path, begin: Timestamp) -> AudioFile: The ``AudioFile`` instance. """ - return AudioFile(path=path, begin=begin) + return AudioFile.from_dict(serialized=file_dict) def get_normalization_values(self) -> dict: """Return the values used for normalizing the audio data. @@ -671,7 +661,7 @@ def _from_base_dict( instrument=instrument, sample_rate=dictionary["sample_rate"], normalization=Normalization(dictionary["normalization"]), - normalization_values=dictionary["normalization_values"], + normalization_values=dictionary.get("normalization_values", None), butter=butter, ) diff --git a/src/osekit/core/audio_file.py b/src/osekit/core/audio_file.py index f35cad543..10b0b7658 100644 --- a/src/osekit/core/audio_file.py +++ b/src/osekit/core/audio_file.py @@ -5,6 +5,9 @@ import typing from typing import TYPE_CHECKING +from osekit.config import TIMESTAMP_FORMATS_EXPORTED_FILES +from osekit.utils.timestamp import strptime_from_text + if TYPE_CHECKING: from os import PathLike from pathlib import Path @@ -30,6 +33,7 @@ def __init__( begin: Timestamp | None = None, strptime_format: str | list[str] | None = None, timezone: str | pytz.timezone | None = None, + **kwargs: dict, ) -> None: """Initialize an ``AudioFile`` object with a path and a begin timestamp. @@ -55,7 +59,8 @@ def __init__( If different from a timezone parsed from the filename, the timestamps' timezone will be converted from the parsed timezone to the specified timezone. - + kwargs: dict + Audio file info that might bypass the afm.info() call on deserialization. """ super().__init__( path=path, @@ -63,13 +68,25 @@ def __init__( strptime_format=strptime_format, timezone=timezone, ) - sample_rate, frames, channels = afm.info(path) - duration = frames / sample_rate + sample_rate, channels, end = self._get_info(path=path, kwargs=kwargs) self.sample_rate = sample_rate self.channels = channels - self.end = self.begin + Timedelta(seconds=duration) + self.end = end self._check_validity() + def _get_info(self, path: Path, kwargs: dict) -> tuple[int, int, Timestamp]: + keys = ["sample_rate", "channels", "end"] + if all(key in kwargs for key in keys): + end = strptime_from_text( + text=kwargs["end"], + datetime_template=TIMESTAMP_FORMATS_EXPORTED_FILES, + ) + return kwargs["sample_rate"], kwargs["channels"], end + sample_rate, frames, channels = afm.info(path=path) + duration = frames / sample_rate + end = self.begin + Timedelta(seconds=duration) + return sample_rate, channels, end + def _check_validity(self) -> None: """Raise an error if the audio file is not valid.""" if not self.duration: @@ -169,3 +186,17 @@ def stream(self, chunk_size: int) -> np.ndarray: if data.ndim == 1: return data[:, None] # 2D array to match the format of multichannel audio return data + + def to_dict(self) -> dict: + """Serialize an ``AudioFile`` to a dictionary. + + Returns + ------- + dict: + The serialized dictionary representing the ``AudioFile``. + + """ + return super().to_dict() | { + "channels": self.channels, + "sample_rate": self.sample_rate, + } diff --git a/src/osekit/core/base_data.py b/src/osekit/core/base_data.py index 7e47cdca3..18324c733 100644 --- a/src/osekit/core/base_data.py +++ b/src/osekit/core/base_data.py @@ -18,12 +18,10 @@ DPDEFAULT, TIMESTAMP_FORMAT_AUDIO_FILE, TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED, - TIMESTAMP_FORMATS_EXPORTED_FILES, ) from osekit.core.base_file import BaseFile from osekit.core.base_item import BaseItem from osekit.core.event import Event -from osekit.utils.timestamp import strptime_from_text TItem = TypeVar("TItem", bound=BaseItem) TFile = TypeVar("TFile", bound=BaseFile) @@ -212,14 +210,7 @@ def from_dict( """ files = [ - cls._make_file( - path=Path(file["path"]), - begin=strptime_from_text( - file["begin"], - datetime_template=TIMESTAMP_FORMATS_EXPORTED_FILES, - ), - ) - for file in dictionary["files"].values() + cls._make_file(file_dict=file) for file in dictionary["files"].values() ] begin = Timestamp(dictionary["begin"]) end = Timestamp(dictionary["end"]) @@ -233,7 +224,7 @@ def from_dict( @classmethod @abstractmethod - def _make_file(cls, path: Path, begin: Timestamp) -> type[TFile]: + def _make_file(cls, file_dict: dict) -> type[TFile]: """Make a File from a path and a begin timestamp.""" ... diff --git a/src/osekit/core/base_file.py b/src/osekit/core/base_file.py index f3df8fc4a..5e9fe399e 100644 --- a/src/osekit/core/base_file.py +++ b/src/osekit/core/base_file.py @@ -45,6 +45,7 @@ def __init__( end: Timestamp | None = None, strptime_format: str | list[str] | None = None, timezone: str | pytz.timezone | None = None, + **kwargs: dict, ) -> None: """Initialize a File object with a path and timestamps. @@ -145,13 +146,15 @@ def from_dict(cls: type[Self], serialized: dict) -> type[Self]: The deserialized File object. """ - path = serialized["path"] + path = serialized.pop("path") + begin = strptime_from_text( + text=serialized.pop("begin"), + datetime_template=TIMESTAMP_FORMATS_EXPORTED_FILES, + ) return cls( path=path, - begin=strptime_from_text( - text=serialized["begin"], - datetime_template=TIMESTAMP_FORMATS_EXPORTED_FILES, - ), + begin=begin, + **serialized, ) def __hash__(self) -> int: diff --git a/src/osekit/core/spectro_data.py b/src/osekit/core/spectro_data.py index 82dcb3610..d0aab90b5 100644 --- a/src/osekit/core/spectro_data.py +++ b/src/osekit/core/spectro_data.py @@ -719,15 +719,13 @@ def get_overlapped_bins(cls, sd1: SpectroData, sd2: SpectroData) -> np.ndarray: return sd_part1[:, -p1_le:] + sd_part2[:, :p1_le] @classmethod - def _make_file(cls, path: Path, begin: Timestamp) -> SpectroFile: + def _make_file(cls, file_dict: dict) -> SpectroFile: """Make a ``SpectroFile`` from a ``path`` and a ``begin`` timestamp. Parameters ---------- - path: Path - Path to the file. - begin: Timestamp - Begin of the file. + file_dict: dict + Serialized SpectroFile Returns ------- @@ -735,7 +733,7 @@ def _make_file(cls, path: Path, begin: Timestamp) -> SpectroFile: The ``SpectroFile`` instance. """ - return SpectroFile(path=path, begin=begin) + return SpectroFile.from_dict(serialized=file_dict) @classmethod def _make_item( diff --git a/src/osekit/core/spectro_file.py b/src/osekit/core/spectro_file.py index 041e6db84..6ea963355 100644 --- a/src/osekit/core/spectro_file.py +++ b/src/osekit/core/spectro_file.py @@ -36,6 +36,7 @@ def __init__( begin: Timestamp | None = None, strptime_format: str | list[str] | None = None, timezone: str | pytz.timezone | None = None, + **kwargs: dict, ) -> None: """Initialize a ``SpectroFile`` object from a ``path`` and begin timestamp. diff --git a/tests/helpers/dummy.py b/tests/helpers/dummy.py index 28b337341..1d115266f 100644 --- a/tests/helpers/dummy.py +++ b/tests/helpers/dummy.py @@ -5,10 +5,12 @@ import numpy as np from pandas import Timestamp +from osekit.config import TIMESTAMP_FORMATS_EXPORTED_FILES from osekit.core.base_data import BaseData, TFile from osekit.core.base_dataset import BaseDataset, TData from osekit.core.base_file import BaseFile from osekit.core.base_item import BaseItem +from osekit.utils.timestamp import strptime_from_text class DummyFile(BaseFile): @@ -37,8 +39,13 @@ def _make_split_data( return DummyData.from_files(files=files, begin=begin, end=end, **kwargs) @classmethod - def _make_file(cls, path: Path, begin: Timestamp) -> DummyFile: - return DummyFile(path=path, begin=begin) + def _make_file(cls, file_dict: dict) -> DummyFile: + if "end" in file_dict: + file_dict["end"] = strptime_from_text( + text=file_dict["end"], + datetime_template=TIMESTAMP_FORMATS_EXPORTED_FILES, + ) + return DummyFile.from_dict(serialized=file_dict) @classmethod def _make_item( diff --git a/tests/test_core_api_base.py b/tests/test_core_api_base.py index ebce7377e..1946996e8 100644 --- a/tests/test_core_api_base.py +++ b/tests/test_core_api_base.py @@ -2336,7 +2336,17 @@ def test_dummydata_make_file() -> None: ), ] dd = DummyData.from_files(dfs) - assert dd._make_file(Path("foo"), begin=Timestamp("2020-01-01 00:00:00")) == dfs[0] + assert ( + dd._make_file( + { + "path": "foo", + "begin": Timestamp("2020-01-01 00:00:00").strftime( + TIMESTAMP_FORMATS_EXPORTED_FILES[0] + ), + } + ) + == dfs[0] + ) def test_dummydata_from_base_dict() -> None: diff --git a/tests/test_serialization.py b/tests/test_serialization.py index c9ddb8468..6d2ba6df5 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path, PureWindowsPath +from typing import Any import numpy as np import pytest @@ -8,6 +9,8 @@ from scipy.signal import ShortTimeFFT from scipy.signal.windows import hamming, hann +import osekit.core +from osekit.audio_backend.audio_file_manager import AudioFileManager from osekit.config import ( TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED, TIMESTAMP_FORMAT_EXPORTED_FILES_UNLOCALIZED, @@ -15,6 +18,7 @@ ) from osekit.core.audio_data import AudioData from osekit.core.audio_dataset import AudioDataset +import osekit.core.audio_file from osekit.core.audio_file import AudioFile from osekit.core.frequency_scale import Scale, ScalePart from osekit.core.instrument import Instrument @@ -29,6 +33,60 @@ from osekit.utils.audio import Normalization +def test_audio_file_from_dict_depends_on_available_info( + audio_files: tuple[list[AudioFile], Any], monkeypatch: pytest.MonkeyPatch +) -> None: + audio_files, _ = audio_files + af = audio_files[0] + minimum = { + "begin": af.begin.strftime(TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED), + "end": af.end.strftime(TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED), + "path": af.path, + } + full = minimum | { + "sample_rate": af.sample_rate, + "channels": af.channels, + } + + afm_calls = [0] + + info_original = AudioFileManager.info + afm = osekit.core.audio_file.afm + + def patch_afm_info(*args, **kwargs) -> tuple[int, int, int]: + afm_calls[0] += 1 + return info_original(self=afm, *args, **kwargs) + + monkeypatch.setattr(afm, "info", patch_afm_info) + + # AudioFile deserialization with minimum info should call afm info to read metadata + AudioFile.from_dict(minimum) + assert afm_calls[0] == 1 + + # AudioFile deserialization with full info should not call afm info + AudioFile.from_dict(full) + assert afm_calls[0] == 1 + + +def test_audio_file_to_dict_should_contain_afm_info( + audio_files: tuple[list[AudioFile], Any], monkeypatch: pytest.MonkeyPatch +) -> None: + audio_files, _ = audio_files + af = audio_files[0] + + minimum = { + "begin": af.begin.strftime(TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED), + "end": af.end.strftime(TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED), + "path": af.path, + } + full = minimum | { + "sample_rate": af.sample_rate, + "channels": af.channels, + } + + assert all(key in af.to_dict() for key in full) + + @pytest.mark.parametrize( ("audio_files", "begin", "end", "sample_rate", "normalization"), [