From bf1d920ddd42a9df3e280447a2fedc450daef7a0 Mon Sep 17 00:00:00 2001 From: Gautzilla <72027971+Gautzilla@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:00:10 +0200 Subject: [PATCH 1/7] =?UTF-8?q?add=20test=20for=20IO=20bypass=20on=20audio?= =?UTF-8?q?=5Ffile.from=5Fdict(=C3=83)=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_audio.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_audio.py b/tests/test_audio.py index 99fffd0b6..6f8cedc74 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2300,3 +2300,17 @@ def mocked_get_value(*args: Any, **kwargs: Any) -> np.ndarray: # Values are provided and shouldn't be fetched again assert get_value_calls[0] == 1 assert np.array_equal(kwargs, {"the": "voidz"}) + + +def test_audio_file_from_dict_doesnt_read_metadata( + audio_files: tuple[list[AudioFile], Any], monkeypatch: pytest.MonkeyPatch +) -> None: + audio_files, _ = audio_files + + def patch_file_open(*args, **kwargs) -> None: + msg = "Deserialization from dict should bypass file IO" + raise ValueError(msg) + + monkeypatch.setattr(AudioFileManager, "info", patch_file_open) + + AudioFile.from_dict(audio_files[0].to_dict()) From 3f82e6c26763f1160f0705015ed7ce8ad6d2e165 Mon Sep 17 00:00:00 2001 From: Gautzilla <72027971+Gautzilla@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:52:48 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=C3=83move=20afm.info=20call=20tests=20to?= =?UTF-8?q?=20serialization=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_audio.py | 14 ---------- tests/test_serialization.py | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index 6f8cedc74..99fffd0b6 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2300,17 +2300,3 @@ def mocked_get_value(*args: Any, **kwargs: Any) -> np.ndarray: # Values are provided and shouldn't be fetched again assert get_value_calls[0] == 1 assert np.array_equal(kwargs, {"the": "voidz"}) - - -def test_audio_file_from_dict_doesnt_read_metadata( - audio_files: tuple[list[AudioFile], Any], monkeypatch: pytest.MonkeyPatch -) -> None: - audio_files, _ = audio_files - - def patch_file_open(*args, **kwargs) -> None: - msg = "Deserialization from dict should bypass file IO" - raise ValueError(msg) - - monkeypatch.setattr(AudioFileManager, "info", patch_file_open) - - AudioFile.from_dict(audio_files[0].to_dict()) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index c9ddb8468..bda82e710 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,7 @@ from scipy.signal import ShortTimeFFT from scipy.signal.windows import hamming, hann +from osekit.audio_backend.audio_file_manager import AudioFileManager from osekit.config import ( TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED, TIMESTAMP_FORMAT_EXPORTED_FILES_UNLOCALIZED, @@ -29,6 +31,59 @@ 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] + + afm_info = AudioFileManager.info + + def patch_afm_info(*args, **kwargs) -> tuple[int, int, int]: + afm_calls[0] += 1 + return afm_info(*args, **kwargs) + + monkeypatch.setattr(AudioFileManager, "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"), [ From 23d4150c04337b078c846a91bb4cbb56fbe1dde8 Mon Sep 17 00:00:00 2001 From: Gautzilla <72027971+Gautzilla@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:53:40 +0200 Subject: [PATCH 3/7] pass rest of serialized dict to files constructor --- src/osekit/core/audio_file.py | 27 ++++++++++++++++++++++----- src/osekit/core/base_file.py | 13 ++++++++----- src/osekit/core/spectro_file.py | 1 + 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/osekit/core/audio_file.py b/src/osekit/core/audio_file.py index 0c133f933..4eb9a01ed 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,11 +68,23 @@ 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.channels = (channels,) + self.end = end + + 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 read(self, start: Timestamp, stop: Timestamp) -> np.ndarray: """Return the audio data between start and stop from the file. 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_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. From 990a43dc47d902456870e3d9c579cde6e125d262 Mon Sep 17 00:00:00 2001 From: Gautzilla <72027971+Gautzilla@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:39:49 +0200 Subject: [PATCH 4/7] add sample_rate and channels properties in AudioFile dict serialization --- src/osekit/core/audio_file.py | 16 +++++++++++++++- tests/test_serialization.py | 9 ++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/osekit/core/audio_file.py b/src/osekit/core/audio_file.py index 4eb9a01ed..009b56b46 100644 --- a/src/osekit/core/audio_file.py +++ b/src/osekit/core/audio_file.py @@ -70,7 +70,7 @@ def __init__( ) sample_rate, channels, end = self._get_info(path=path, kwargs=kwargs) self.sample_rate = sample_rate - self.channels = (channels,) + self.channels = channels self.end = end def _get_info(self, path: Path, kwargs: dict) -> tuple[int, int, Timestamp]: @@ -176,3 +176,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/tests/test_serialization.py b/tests/test_serialization.py index bda82e710..6d2ba6df5 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -9,6 +9,7 @@ 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, @@ -17,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 @@ -48,13 +50,14 @@ def test_audio_file_from_dict_depends_on_available_info( afm_calls = [0] - afm_info = AudioFileManager.info + 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 afm_info(*args, **kwargs) + return info_original(self=afm, *args, **kwargs) - monkeypatch.setattr(AudioFileManager, "info", patch_afm_info) + monkeypatch.setattr(afm, "info", patch_afm_info) # AudioFile deserialization with minimum info should call afm info to read metadata AudioFile.from_dict(minimum) From 023ca6ba7a12d6e16b2050be69302234a3e7afbd Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 23 Jul 2026 16:52:04 +0200 Subject: [PATCH 5/7] =?UTF-8?q?pass=20kwargs=20to=20File=20constructor=20i?= =?UTF-8?q?n=20AudioData.=5Fmake=5Ffile(=C3=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/osekit/core/audio_data.py | 6 ++++-- src/osekit/core/base_data.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/osekit/core/audio_data.py b/src/osekit/core/audio_data.py index 806f8e797..154e78315 100644 --- a/src/osekit/core/audio_data.py +++ b/src/osekit/core/audio_data.py @@ -165,7 +165,7 @@ 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, path: Path, begin: Timestamp, **kwargs: dict) -> AudioFile: """Make an ``AudioFile`` from a path and a begin timestamp. Parameters @@ -174,6 +174,8 @@ def _make_file(cls, path: Path, begin: Timestamp) -> AudioFile: Path to the file. begin: Timestamp Begin of the file. + kwargs: dict + Additional keyword arguments for the File constructor. Returns ------- @@ -181,7 +183,7 @@ def _make_file(cls, path: Path, begin: Timestamp) -> AudioFile: The ``AudioFile`` instance. """ - return AudioFile(path=path, begin=begin) + return AudioFile(path=path, begin=begin, **kwargs) def get_normalization_values(self) -> dict: """Return the values used for normalizing the audio data. diff --git a/src/osekit/core/base_data.py b/src/osekit/core/base_data.py index 7e47cdca3..9fb7e4dc4 100644 --- a/src/osekit/core/base_data.py +++ b/src/osekit/core/base_data.py @@ -218,6 +218,7 @@ def from_dict( file["begin"], datetime_template=TIMESTAMP_FORMATS_EXPORTED_FILES, ), + **{key: file[key] for key in file if key not in ["path", "begin"]}, ) for file in dictionary["files"].values() ] @@ -233,7 +234,7 @@ def from_dict( @classmethod @abstractmethod - def _make_file(cls, path: Path, begin: Timestamp) -> type[TFile]: + def _make_file(cls, path: Path, begin: Timestamp, **kwargs: dict) -> type[TFile]: """Make a File from a path and a begin timestamp.""" ... From 9c8c463b20ac4110c99c1486b2c7a9926bafc567 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 23 Jul 2026 17:20:11 +0200 Subject: [PATCH 6/7] =?UTF-8?q?simplify=20=5Fmake=5Ffile(=C3=83)=20methods?= =?UTF-8?q?=20through=20File.from=5Fdict()=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/osekit/core/audio_data.py | 12 ++++-------- src/osekit/core/base_data.py | 14 ++------------ src/osekit/core/spectro_data.py | 10 ++++------ tests/helpers/dummy.py | 11 +++++++++-- tests/test_core_api_base.py | 12 +++++++++++- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/osekit/core/audio_data.py b/src/osekit/core/audio_data.py index 154e78315..b7991a25c 100644 --- a/src/osekit/core/audio_data.py +++ b/src/osekit/core/audio_data.py @@ -165,17 +165,13 @@ def _make_item( return AudioItem(file=file, begin=begin, end=end) @classmethod - def _make_file(cls, path: Path, begin: Timestamp, **kwargs: dict) -> 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. - kwargs: dict - Additional keyword arguments for the File constructor. + file_dict: dict + Serialized AudioFile. Returns ------- @@ -183,7 +179,7 @@ def _make_file(cls, path: Path, begin: Timestamp, **kwargs: dict) -> AudioFile: The ``AudioFile`` instance. """ - return AudioFile(path=path, begin=begin, **kwargs) + return AudioFile.from_dict(serialized=file_dict) def get_normalization_values(self) -> dict: """Return the values used for normalizing the audio data. diff --git a/src/osekit/core/base_data.py b/src/osekit/core/base_data.py index 9fb7e4dc4..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,15 +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, - ), - **{key: file[key] for key in file if key not in ["path", "begin"]}, - ) - 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"]) @@ -234,7 +224,7 @@ def from_dict( @classmethod @abstractmethod - def _make_file(cls, path: Path, begin: Timestamp, **kwargs: dict) -> 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/spectro_data.py b/src/osekit/core/spectro_data.py index 47f195816..99d21165e 100644 --- a/src/osekit/core/spectro_data.py +++ b/src/osekit/core/spectro_data.py @@ -713,15 +713,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 ------- @@ -729,7 +727,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/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: From 97e1ed93c8e07c86abc43cd6a8a9afbc3e4dd0b8 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 28 Jul 2026 14:52:20 +0200 Subject: [PATCH 7/7] make normalization_values an optional key for retrocompatibility --- src/osekit/core/audio_data.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/osekit/core/audio_data.py b/src/osekit/core/audio_data.py index b7991a25c..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: @@ -669,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, )