From 48c8578b3224027b2d26b0db25e2f9793f287846 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Mon, 16 Feb 2026 16:36:24 +0100 Subject: [PATCH 1/8] add seek methods --- src/osekit/audio_backend/audio_backend.py | 2 ++ src/osekit/audio_backend/audio_file_manager.py | 3 +++ src/osekit/audio_backend/mseed_backend.py | 3 +++ src/osekit/audio_backend/soundfile_backend.py | 7 +++++-- src/osekit/core_api/audio_file.py | 3 +++ src/osekit/core_api/audio_item.py | 12 ++++++++++++ 6 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/osekit/audio_backend/audio_backend.py b/src/osekit/audio_backend/audio_backend.py index f130ba24..599aaf89 100644 --- a/src/osekit/audio_backend/audio_backend.py +++ b/src/osekit/audio_backend/audio_backend.py @@ -47,3 +47,5 @@ def read(self, path: PathLike | str, start: int, stop: int) -> np.ndarray: def close(self) -> None: """Close the currently opened file.""" ... + + def seek(self, path: PathLike, frame: int) -> None: ... diff --git a/src/osekit/audio_backend/audio_file_manager.py b/src/osekit/audio_backend/audio_file_manager.py index 7ff42de6..b1df4591 100644 --- a/src/osekit/audio_backend/audio_file_manager.py +++ b/src/osekit/audio_backend/audio_file_manager.py @@ -102,3 +102,6 @@ def read( raise ValueError(msg) return self._backend(path).read(path=path, start=start, stop=stop) + + def seek(self, path: Path, frame: int) -> None: + self._backend(path=path).seek(path=path, frame=frame) diff --git a/src/osekit/audio_backend/mseed_backend.py b/src/osekit/audio_backend/mseed_backend.py index d9e86e4e..6472eaac 100644 --- a/src/osekit/audio_backend/mseed_backend.py +++ b/src/osekit/audio_backend/mseed_backend.py @@ -83,3 +83,6 @@ def read( data = np.concatenate([trace.data for trace in file_content]) return data[start:stop] + + def seek(self, path: PathLike, frame: int) -> None: + pass diff --git a/src/osekit/audio_backend/soundfile_backend.py b/src/osekit/audio_backend/soundfile_backend.py index 11cc36ea..946ce0d2 100644 --- a/src/osekit/audio_backend/soundfile_backend.py +++ b/src/osekit/audio_backend/soundfile_backend.py @@ -65,10 +65,13 @@ def read( A ``(channel * frames)`` array containing the audio data. """ - self._switch(path) - self._file.seek(start) + self.seek(path=path, frame=start) return self._file.read(stop - start) + def seek(self, path: PathLike, frame: int) -> None: + self._switch(path=path) + self._file.seek(frame) + def _close(self) -> None: if self._file is None: return diff --git a/src/osekit/core_api/audio_file.py b/src/osekit/core_api/audio_file.py index f825d376..f89daf48 100644 --- a/src/osekit/core_api/audio_file.py +++ b/src/osekit/core_api/audio_file.py @@ -132,3 +132,6 @@ def move(self, folder: Path) -> None: """ afm.close() super().move(folder) + + def seek(self, frame: int) -> None: + afm.seek(path=self.path, frame=frame) diff --git a/src/osekit/core_api/audio_item.py b/src/osekit/core_api/audio_item.py index 20fdacea..77612323 100644 --- a/src/osekit/core_api/audio_item.py +++ b/src/osekit/core_api/audio_item.py @@ -64,3 +64,15 @@ def get_value(self) -> np.ndarray: if self.is_empty: return np.zeros((1, self.nb_channels)) return super().get_value() + + def stream(self, chunk_size: int) -> np.ndarray: + start_frame, stop_frame = self.file.frames_indexes( + start=self.begin, stop=self.end + ) + + remaining = stop_frame - start_frame + + self.file.seek(frame=start_frame) + + while remaining > 0: + pass From 349f8a0cea7b2d5259d85617b300aac23881cc12 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Mon, 16 Feb 2026 18:22:19 +0100 Subject: [PATCH 2/8] add stream(() methods --- src/osekit/audio_backend/audio_backend.py | 2 ++ src/osekit/audio_backend/audio_file_manager.py | 3 +++ src/osekit/audio_backend/mseed_backend.py | 3 +++ src/osekit/audio_backend/soundfile_backend.py | 4 ++++ src/osekit/core_api/audio_file.py | 3 +++ src/osekit/core_api/audio_item.py | 10 +++++++--- 6 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/osekit/audio_backend/audio_backend.py b/src/osekit/audio_backend/audio_backend.py index 599aaf89..9eadc890 100644 --- a/src/osekit/audio_backend/audio_backend.py +++ b/src/osekit/audio_backend/audio_backend.py @@ -49,3 +49,5 @@ def close(self) -> None: ... def seek(self, path: PathLike, frame: int) -> None: ... + + def stream(self, path: PathLike, chunk_size: int) -> np.ndarray: ... diff --git a/src/osekit/audio_backend/audio_file_manager.py b/src/osekit/audio_backend/audio_file_manager.py index b1df4591..a3d836e4 100644 --- a/src/osekit/audio_backend/audio_file_manager.py +++ b/src/osekit/audio_backend/audio_file_manager.py @@ -105,3 +105,6 @@ def read( def seek(self, path: Path, frame: int) -> None: self._backend(path=path).seek(path=path, frame=frame) + + def stream(self, path: Path, chunk_size: int) -> np.ndarray: + self._backend(path=path).stream(path=path, chunk_size=chunk_size) diff --git a/src/osekit/audio_backend/mseed_backend.py b/src/osekit/audio_backend/mseed_backend.py index 6472eaac..b1218f6e 100644 --- a/src/osekit/audio_backend/mseed_backend.py +++ b/src/osekit/audio_backend/mseed_backend.py @@ -86,3 +86,6 @@ def read( def seek(self, path: PathLike, frame: int) -> None: pass + + def stream(self, path: PathLike, chunk_size: int) -> np.ndarray: + pass diff --git a/src/osekit/audio_backend/soundfile_backend.py b/src/osekit/audio_backend/soundfile_backend.py index 946ce0d2..7bce1119 100644 --- a/src/osekit/audio_backend/soundfile_backend.py +++ b/src/osekit/audio_backend/soundfile_backend.py @@ -72,6 +72,10 @@ def seek(self, path: PathLike, frame: int) -> None: self._switch(path=path) self._file.seek(frame) + def stream(self, path: PathLike, chunk_size: int) -> np.ndarray: + self._switch(path=path) + return self._file.read(frames=chunk_size) + def _close(self) -> None: if self._file is None: return diff --git a/src/osekit/core_api/audio_file.py b/src/osekit/core_api/audio_file.py index f89daf48..bd97d95f 100644 --- a/src/osekit/core_api/audio_file.py +++ b/src/osekit/core_api/audio_file.py @@ -135,3 +135,6 @@ def move(self, folder: Path) -> None: def seek(self, frame: int) -> None: afm.seek(path=self.path, frame=frame) + + def stream(self, chunk_size: int) -> np.ndarray: + return afm.stream(path=self.path, chunk_size=chunk_size) diff --git a/src/osekit/core_api/audio_item.py b/src/osekit/core_api/audio_item.py index 77612323..ef5bfcbb 100644 --- a/src/osekit/core_api/audio_item.py +++ b/src/osekit/core_api/audio_item.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from typing import TYPE_CHECKING import numpy as np @@ -65,9 +66,10 @@ def get_value(self) -> np.ndarray: return np.zeros((1, self.nb_channels)) return super().get_value() - def stream(self, chunk_size: int) -> np.ndarray: + def stream(self, chunk_size: int) -> Generator[np.ndarray, None, None]: start_frame, stop_frame = self.file.frames_indexes( - start=self.begin, stop=self.end + start=self.begin, + stop=self.end, ) remaining = stop_frame - start_frame @@ -75,4 +77,6 @@ def stream(self, chunk_size: int) -> np.ndarray: self.file.seek(frame=start_frame) while remaining > 0: - pass + frames_to_read = min(chunk_size, remaining) + yield self.file.stream(chunk_size=frames_to_read) + remaining -= frames_to_read From 7c90852d7dcc9f261ef30151b22b3ea7dfc141f0 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 17 Feb 2026 16:35:06 +0100 Subject: [PATCH 3/8] add soxr streamed resample --- .../audio_backend/audio_file_manager.py | 2 +- src/osekit/core_api/audio_data.py | 56 +++++++++++++++++++ src/osekit/core_api/audio_file.py | 12 ++-- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/osekit/audio_backend/audio_file_manager.py b/src/osekit/audio_backend/audio_file_manager.py index a3d836e4..da0bbdbb 100644 --- a/src/osekit/audio_backend/audio_file_manager.py +++ b/src/osekit/audio_backend/audio_file_manager.py @@ -107,4 +107,4 @@ def seek(self, path: Path, frame: int) -> None: self._backend(path=path).seek(path=path, frame=frame) def stream(self, path: Path, chunk_size: int) -> np.ndarray: - self._backend(path=path).stream(path=path, chunk_size=chunk_size) + return self._backend(path=path).stream(path=path, chunk_size=chunk_size) diff --git a/src/osekit/core_api/audio_data.py b/src/osekit/core_api/audio_data.py index 081d08b2..55101a42 100644 --- a/src/osekit/core_api/audio_data.py +++ b/src/osekit/core_api/audio_data.py @@ -6,13 +6,16 @@ from __future__ import annotations +from collections.abc import Generator from math import ceil from typing import TYPE_CHECKING, Self import numpy as np import soundfile as sf +import soxr from pandas import Timedelta, Timestamp +from osekit.config import resample_quality_settings from osekit.core_api.audio_file import AudioFile from osekit.core_api.audio_item import AudioItem from osekit.core_api.base_data import BaseData @@ -216,6 +219,7 @@ def get_raw_value(self) -> np.ndarray: The value of the audio data. """ + return np.vstack(list(self.stream())) data = np.empty(shape=self.shape) idx = 0 for item in self.items: @@ -225,6 +229,58 @@ def get_raw_value(self) -> np.ndarray: idx += len(item_data) return data + def stream(self, chunk_size: int = 8192) -> Generator[np.ndarray, None, None]: + resampler = None + input_sr = None + produced_samples = 0 + total_samples = self.length + + for item in self.items: + if item.is_empty: + silence_length = round(item.duration.total_seconds() * self.sample_rate) + yield item.get_value().repeat( + silence_length, + axis=0, + ) + produced_samples += silence_length + continue + + if (resampler is None) or (input_sr != item.sample_rate): + input_sr = item.sample_rate + quality = resample_quality_settings[ + "downsample" if input_sr > self.sample_rate else "upsample" + ] + resampler = soxr.ResampleStream( + in_rate=input_sr, + out_rate=self.sample_rate, + num_channels=self.nb_channels, + quality=quality, + dtype=np.float64, + ) + + for chunk in item.stream(chunk_size=chunk_size): + y = chunk + if item.sample_rate != self.sample_rate: + y = resampler.resample_chunk(x=chunk) + + remaining = total_samples - produced_samples + y = y[:remaining] + produced_samples += len(y) + + yield y + + if produced_samples >= total_samples: + return + + if resampler is None: + return + + flush = resampler.resample_chunk(np.array([]), last=True) + if len(flush) and (remaining := (total_samples - produced_samples)): + if flush.ndim == 1: + flush = flush[:, None] + yield flush[:remaining] + def get_value(self) -> np.ndarray: """Return the value of the audio data. diff --git a/src/osekit/core_api/audio_file.py b/src/osekit/core_api/audio_file.py index bd97d95f..0bfea239 100644 --- a/src/osekit/core_api/audio_file.py +++ b/src/osekit/core_api/audio_file.py @@ -89,11 +89,8 @@ def read(self, start: Timestamp, stop: Timestamp) -> np.ndarray: """ start_sample, stop_sample = self.frames_indexes(start, stop) data = afm.read(self.path, start=start_sample, stop=stop_sample) - if len(data.shape) == 1: - return data.reshape( - data.shape[0], - 1, - ) # 2D array to match the format of multichannel audio + if data.ndim == 1: + return data[:, None] # 2D array to match the format of multichannel audio return data def frames_indexes(self, start: Timestamp, stop: Timestamp) -> tuple[int, int]: @@ -137,4 +134,7 @@ def seek(self, frame: int) -> None: afm.seek(path=self.path, frame=frame) def stream(self, chunk_size: int) -> np.ndarray: - return afm.stream(path=self.path, chunk_size=chunk_size) + data = afm.stream(path=self.path, chunk_size=chunk_size) + if data.ndim == 1: + return data[:, None] # 2D array to match the format of multichannel audio + return data From 0a4dc5b745ca56c847d153e13533ac1c99b739c3 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 18 Feb 2026 17:18:00 +0100 Subject: [PATCH 4/8] =?UTF-8?q?add=20stream()=20and=20seek(=C3=83)=20place?= =?UTF-8?q?holders=20for=20MSEED=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/osekit/audio_backend/mseed_backend.py | 33 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/osekit/audio_backend/mseed_backend.py b/src/osekit/audio_backend/mseed_backend.py index b1218f6e..e15ac5e0 100644 --- a/src/osekit/audio_backend/mseed_backend.py +++ b/src/osekit/audio_backend/mseed_backend.py @@ -19,6 +19,7 @@ class MSeedBackend: def __init__(self) -> None: """Initialize the MSEED backend.""" _require_obspy() + self.seeked_frame = 0 def close(self) -> None: """Close the currently opened file. No use in MSEED files.""" @@ -85,7 +86,35 @@ def read( return data[start:stop] def seek(self, path: PathLike, frame: int) -> None: - pass + """Set the seeked_frame of the backend. + + Streamed data will be streamed from this frame. + + Parameters + ---------- + path: PathLike | str + No effect. + frame: int + Frame to seek. + + """ + self.seeked_frame = frame def stream(self, path: PathLike, chunk_size: int) -> np.ndarray: - pass + """Stream the content of the MSEED file from the seeked frame. + + Parameters + ---------- + path: PathLike + Path to the mseed file. + chunk_size: int + Number of frames to stream. + + Returns + ------- + np.ndarray: + Streamed data of length ``chunk_size`` from ``self.seeked_frame``. + """ + return self.read( + path=path, start=self.seeked_frame, stop=self.seeked_frame + chunk_size + ) From 4316a70e61e0478624613d9de7cba3be18b87fba Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 18 Feb 2026 17:47:03 +0100 Subject: [PATCH 5/8] add docstrings in seek and stream functions --- src/osekit/core_api/audio_data.py | 36 +++++++++++++------------------ src/osekit/core_api/audio_file.py | 21 ++++++++++++++++++ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/osekit/core_api/audio_data.py b/src/osekit/core_api/audio_data.py index 55101a42..2b4b063f 100644 --- a/src/osekit/core_api/audio_data.py +++ b/src/osekit/core_api/audio_data.py @@ -20,7 +20,7 @@ from osekit.core_api.audio_item import AudioItem from osekit.core_api.base_data import BaseData from osekit.core_api.instrument import Instrument -from osekit.utils.audio_utils import Normalization, normalize, resample +from osekit.utils.audio_utils import Normalization, normalize if TYPE_CHECKING: from pathlib import Path @@ -220,16 +220,22 @@ def get_raw_value(self) -> np.ndarray: """ return np.vstack(list(self.stream())) - data = np.empty(shape=self.shape) - idx = 0 - for item in self.items: - item_data = self._get_item_value(item) - item_data = item_data[: min(item_data.shape[0], data.shape[0] - idx)] - data[idx : idx + len(item_data)] = item_data - idx += len(item_data) - return data def stream(self, chunk_size: int = 8192) -> Generator[np.ndarray, None, None]: + """Stream the audio data in chunks. + + Parameters + ---------- + chunk_size: int + Size of the chunks of audio yielded by the generator. + + Returns + ------- + Generator[np.ndarray, None, None]: + Generated ``np.ndarray`` of dimensions (``chunk_size``*``self.nb_channels``) + of the streamed audio data. + + """ resampler = None input_sr = None produced_samples = 0 @@ -369,18 +375,6 @@ def link(self, folder: Path) -> None: ) self.items = AudioData.from_files([file]).items - def _get_item_value(self, item: AudioItem) -> np.ndarray: - """Return the resampled (if needed) data from the audio item.""" - item_data = item.get_value() - if item.is_empty: - return item_data.repeat( - round(item.duration.total_seconds() * self.sample_rate), - axis=0, - ) - if item.sample_rate != self.sample_rate: - return resample(item_data, item.sample_rate, self.sample_rate) - return item_data - def split( self, nb_subdata: int = 2, diff --git a/src/osekit/core_api/audio_file.py b/src/osekit/core_api/audio_file.py index 0bfea239..f52c15c6 100644 --- a/src/osekit/core_api/audio_file.py +++ b/src/osekit/core_api/audio_file.py @@ -131,9 +131,30 @@ def move(self, folder: Path) -> None: super().move(folder) def seek(self, frame: int) -> None: + """Seek the requested frame in the file. + + Parameters + ---------- + frame: int + Index of the frame to be seeked. + + """ afm.seek(path=self.path, frame=frame) def stream(self, chunk_size: int) -> np.ndarray: + """Stream ``chunk_size`` frames from the audio file. + + Parameters + ---------- + chunk_size: int + Number of frames to stream from the audio file. + + Returns + ------- + np.ndarray: + A (``chunk_size``*``self.channels``) array of frames. + + """ data = afm.stream(path=self.path, chunk_size=chunk_size) if data.ndim == 1: return data[:, None] # 2D array to match the format of multichannel audio From 9b1597d1f7ae6fffc7e611d49aca3c6f3959309b Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 18 Feb 2026 17:56:52 +0100 Subject: [PATCH 6/8] add tests for AudioFile stream shape --- tests/test_audio.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_audio.py b/tests/test_audio.py index 12e4f049..b3a494c2 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -222,6 +222,32 @@ def test_audio_file_read( assert np.allclose(files[0].read(start, stop)[:, 0], expected, atol=1e-7) +@pytest.mark.parametrize( + ("mocked_data", "expected_shape"), + [ + pytest.param(np.array([0, 1, 2, 3]), (4, 1), id="1d-to-2d"), + pytest.param(np.array([[0, 1], [2, 3], [4, 5], [6, 7]]), (4, 2), id="2d-to-2d"), + ], +) +def test_audio_file_stream_is_always_2d( + monkeypatch: pytest.MonkeyPatch, mocked_data: np.ndarray, expected_shape: tuple +) -> None: + def mocked_stream(*args: None, **kwargs: None) -> np.ndarray: + return mocked_data + + monkeypatch.setattr("osekit.core_api.audio_file.afm.stream", mocked_stream) + + def mocked_init(self: AudioFile, *args: None, **kwargs: None) -> None: + self.path = Path() + self.begin = Timestamp("1996-04-15 00:00:00") + + monkeypatch.setattr(AudioFile, "__init__", mocked_init) + + af = AudioFile() + + assert af.stream(1024).shape == expected_shape + + def test_multichannel_audio_file_read(monkeypatch: pytest.MonkeyPatch) -> None: full_file = np.array([[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5]]) From 78dbd00105c162bca4ef14d99ba7dac37a8f0112 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 19 Feb 2026 11:19:10 +0100 Subject: [PATCH 7/8] add resample from multiple sample rates test --- tests/test_audio.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index b3a494c2..71659d05 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -230,7 +230,9 @@ def test_audio_file_read( ], ) def test_audio_file_stream_is_always_2d( - monkeypatch: pytest.MonkeyPatch, mocked_data: np.ndarray, expected_shape: tuple + monkeypatch: pytest.MonkeyPatch, + mocked_data: np.ndarray, + expected_shape: tuple, ) -> None: def mocked_stream(*args: None, **kwargs: None) -> np.ndarray: return mocked_data @@ -742,6 +744,27 @@ def test_audio_resample_sample_count( assert data.get_value().shape[0] == expected_nb_samples +def test_audio_resample_different_samplerates(tmp_path: Path) -> None: + fs1 = 20 + fs2 = 10 + d1 = 1 + d2 = 1 + s1 = np.linspace(-1.0, 0.0, fs1 * d1) + s2 = np.linspace(0.0, 1.0, fs2 * d2) + + p1 = tmp_path / "s1.wav" + p2 = tmp_path / "s2.wav" + + sf.write(file=p1, data=s1, samplerate=fs1) + sf.write(file=p2, data=s2, samplerate=fs2) + + af1 = AudioFile(path=p1, begin=Timestamp("2020-01-01 00:00:00")) + af2 = AudioFile(path=p2, begin=Timestamp("2020-01-01 00:00:01")) + + ad = AudioData.from_files(files=[af1, af2], sample_rate=10) + assert ad.get_value().shape == (20, 1) + + @pytest.mark.parametrize( ("audio_files", "downsampling_quality", "upsampling_quality"), [ From 12fe6be46f33dce6d20ae3610c30f48f0ee0973c Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 19 Feb 2026 11:19:39 +0100 Subject: [PATCH 8/8] flush resampler before switching to a new one --- src/osekit/core_api/audio_data.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/osekit/core_api/audio_data.py b/src/osekit/core_api/audio_data.py index 2b4b063f..98210010 100644 --- a/src/osekit/core_api/audio_data.py +++ b/src/osekit/core_api/audio_data.py @@ -221,6 +221,19 @@ def get_raw_value(self) -> np.ndarray: """ return np.vstack(list(self.stream())) + @staticmethod + def _flush( + resampler: soxr.ResampleStream, + remaining_samples: int, + ) -> np.ndarray: + flush = resampler.resample_chunk(np.array([]), last=True) + if len(flush) == 0: + return np.array([]) + if not remaining_samples: + return np.array([]) + flush = flush[:remaining_samples] + return flush[:, None] if flush.ndim == 1 else flush + def stream(self, chunk_size: int = 8192) -> Generator[np.ndarray, None, None]: """Stream the audio data in chunks. @@ -252,6 +265,13 @@ def stream(self, chunk_size: int = 8192) -> Generator[np.ndarray, None, None]: continue if (resampler is None) or (input_sr != item.sample_rate): + if resampler: + flush = self._flush( + resampler=resampler, + remaining_samples=total_samples - produced_samples, + ) + yield flush + produced_samples += len(flush[0]) input_sr = item.sample_rate quality = resample_quality_settings[ "downsample" if input_sr > self.sample_rate else "upsample" @@ -281,11 +301,10 @@ def stream(self, chunk_size: int = 8192) -> Generator[np.ndarray, None, None]: if resampler is None: return - flush = resampler.resample_chunk(np.array([]), last=True) - if len(flush) and (remaining := (total_samples - produced_samples)): - if flush.ndim == 1: - flush = flush[:, None] - yield flush[:remaining] + yield self._flush( + resampler=resampler, + remaining_samples=total_samples - produced_samples, + ) def get_value(self) -> np.ndarray: """Return the value of the audio data.