• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Project-OSmOSE / OSEkit / 21365269147

26 Jan 2026 04:25PM UTC coverage: 98.861% (+0.05%) from 98.811%
21365269147

Pull #326

github

web-flow
Merge 6219df604 into c116c7624
Pull Request #326: take into account non zero-padded datetime

32 of 32 new or added lines in 2 files covered. (100.0%)

8 existing lines in 5 files now uncovered.

4689 of 4743 relevant lines covered (98.86%)

0.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.21
/src/osekit/core_api/spectro_data.py
1
"""``SpectroData`` represent spectrogram data retrieved from ``SpectroFiles``.
2

3
The ``SpectroData`` has a collection of ``SpectroItem``.
4
The data is accessed via a ``SpectroItem`` object per ``SpectroFile``.
5

6
Alternatively, the ``SpectroData`` value can be computed from an
7
``AudioData``, thanks to a given ``ShortTimeFFT`` instance.
8

9
"""
10

11
from __future__ import annotations
1✔
12

13
import gc
1✔
14
import itertools
1✔
15
from typing import TYPE_CHECKING, Literal, Self
1✔
16

17
import matplotlib.pyplot as plt
1✔
18
import numpy as np
1✔
19
import pandas as pd
1✔
20
from matplotlib.dates import date2num
1✔
21
from scipy.signal import ShortTimeFFT, welch
1✔
22

23
from osekit.config import (
1✔
24
    TIMESTAMP_FORMATS_EXPORTED_FILES,
25
)
26
from osekit.core_api.audio_data import AudioData
1✔
27
from osekit.core_api.base_data import BaseData, TFile
1✔
28
from osekit.core_api.spectro_file import SpectroFile
1✔
29
from osekit.core_api.spectro_item import SpectroItem
1✔
30

31
if TYPE_CHECKING:
32
    from pathlib import Path
33

34
    from pandas import Timestamp
35

36
    from osekit.core_api.frequency_scale import Scale
37

38

39
class SpectroData(BaseData[SpectroItem, SpectroFile]):
1✔
40
    """``SpectroData`` represent spectrogram data retrieved from ``SpectroFiles``.
41

42
    The ``SpectroData`` has a collection of ``SpectroItem``.
43
    The data is accessed via a ``SpectroItem`` object per ``SpectroFile``.
44

45
    Alternatively, the ``SpectroData`` value can be computed from an
46
    ``AudioData``, thanks to a given ``ShortTimeFFT`` instance.
47
    """
48

49
    item_cls = SpectroItem
1✔
50

51
    def __init__(
1✔
52
        self,
53
        items: list[SpectroItem] | None = None,
54
        audio_data: AudioData = None,
55
        begin: Timestamp | None = None,
56
        end: Timestamp | None = None,
57
        name: str | None = None,
58
        fft: ShortTimeFFT | None = None,
59
        db_ref: float | None = None,
60
        v_lim: tuple[float, float] | None = None,
61
        colormap: str | None = None,
62
    ) -> None:
63
        """Initialize a ``SpectroData``.
64

65
        The ``SpectroData`` can be initialized either from a list of ``SpectroItems``
66
        or from an ``AudioData`` and a given ``ShortTimeFFT`` instance.
67

68
        Parameters
69
        ----------
70
        items: list[SpectroItem]
71
            List of the ``SpectroItem`` constituting the ``SpectroData``.
72
        audio_data: AudioData
73
            The ``AudioData`` from which to compute the spectrogram.
74
        begin: Timestamp | None
75
            Only effective if items is ``None``.
76
            Set the begin of the empty data.
77
        end: Timestamp | None
78
            Only effective if items is ``None``.
79
            Set the end of the empty data.
80
        name: str | None
81
            Name of the exported files.
82
        fft: ShortTimeFFT
83
            The short time FFT used for computing the spectrogram.
84
        db_ref: float | None
85
            Reference value for computing sx values in decibel.
86
        v_lim: tuple[float,float]
87
            Lower and upper limits (in ``dB``) of the colormap used
88
            for plotting the spectrogram.
89
        colormap: str
90
            Colormap to use for plotting the spectrogram.
91

92
        """
93
        super().__init__(items=items, begin=begin, end=end, name=name)
1✔
94
        self.audio_data = audio_data
1✔
95
        self.fft = fft
1✔
96
        self._sx_dtype = complex
1✔
97
        self._db_ref = db_ref
1✔
98
        self.v_lim = v_lim
1✔
99
        self.colormap = "viridis" if colormap is None else colormap
1✔
100
        self.previous_data = None
1✔
101
        self.next_data = None
1✔
102

103
    @staticmethod
1✔
104
    def get_default_ax() -> plt.Axes:
1✔
105
        """Return a default-formatted ``Axes`` on a new figure.
106

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

110
        Returns
111
        -------
112
        plt.Axes:
113
            The default ``Axes`` on a new figure.
114

115
        """
116
        # Legacy OSEkit behaviour.
117
        _, ax = plt.subplots(
1✔
118
            nrows=1,
119
            ncols=1,
120
            figsize=(1813 / 100, 512 / 100),
121
            dpi=100,
122
        )
123

124
        ax.get_xaxis().set_visible(False)
1✔
125
        ax.get_yaxis().set_visible(False)
1✔
126
        ax.set_frame_on(False)
1✔
127
        ax.spines["right"].set_visible(False)
1✔
128
        ax.spines["left"].set_visible(False)
1✔
129
        ax.spines["bottom"].set_visible(False)
1✔
130
        ax.spines["top"].set_visible(False)
1✔
131
        plt.axis("off")
1✔
132
        plt.subplots_adjust(
1✔
133
            top=1,
134
            bottom=0,
135
            right=1,
136
            left=0,
137
            hspace=0,
138
            wspace=0,
139
        )
140
        return ax
1✔
141

142
    @BaseData.end.setter
1✔
143
    def end(self, end: Timestamp | None) -> None:
1✔
144
        """Trim the end timestamp of the data.
145

146
        End can only be set to an anterior date from the original end.
147

148
        """
149
        if end >= self.end:
1✔
150
            return
1✔
151
        if self.audio_data:
1✔
152
            self.audio_data.end = end
1✔
153
        BaseData.end.fset(self, end)
1✔
154

155
    @BaseData.begin.setter
1✔
156
    def begin(self, begin: Timestamp | None) -> None:
1✔
157
        """Trim the begin timestamp of the data.
158

159
        Begin can only be set to a posterior date from the original begin.
160

161
        """
162
        if begin <= self.begin:
1✔
163
            return
1✔
164
        if self.audio_data:
1✔
165
            self.audio_data.begin = begin
1✔
166
        BaseData.begin.fset(self, begin)
1✔
167

168
    @property
1✔
169
    def shape(self) -> tuple[int, ...]:
1✔
170
        """Shape of the ``SpectroData``."""
171
        return self.fft.f_pts, self.fft.p_num(
1✔
172
            int(self.fft.fs * self.duration.total_seconds()),
173
        )
174

175
    @property
1✔
176
    def nb_bytes(self) -> int:
1✔
177
        """Total bytes consumed by the spectro values."""
178
        nb_bytes_per_cell = 16 if self.sx_dtype is complex else 8
1✔
179
        return self.shape[0] * self.shape[1] * nb_bytes_per_cell
1✔
180

181
    @property
1✔
182
    def sx_dtype(self) -> type[complex]:
1✔
183
        """Data type used to represent the sx values.
184

185
        Should either be ``float`` or ``complex``.
186
        If ``complex``, the phase info will be included in the computed spectrum.
187
        If ``float``, only the absolute value of the spectrum will be kept.
188

189
        """
190
        return self._sx_dtype
1✔
191

192
    @sx_dtype.setter
1✔
193
    def sx_dtype(self, dtype: type[complex]) -> None:
1✔
194
        if dtype not in (complex, float):
1✔
195
            msg = "dtype must be complex or float."
1✔
196
            raise ValueError(msg)
1✔
197
        self._sx_dtype = dtype
1✔
198

199
    @property
1✔
200
    def db_ref(self) -> float:
1✔
201
        """Reference value for computing sx values in decibel.
202

203
        If no reference is specified (``self._db_ref is None``), the
204
        sx db values will be given in ``dB FS``.
205
        """
206
        db_type = self.db_type
1✔
207
        if db_type == "SPL_parameter":
1✔
208
            return self._db_ref
1✔
209
        if db_type == "SPL_instrument":
1✔
210
            return self.audio_data.instrument.P_REF
1✔
211
        return 1.0
1✔
212

213
    @db_ref.setter
1✔
214
    def db_ref(self, db_ref: float) -> None:
1✔
215
        self._db_ref = db_ref
1✔
216

217
    @property
1✔
218
    def db_type(self) -> Literal["FS", "SPL_instrument", "SPL_parameter"]:
1✔
219
        """Return whether the spectrogram ``dB`` values are in ``dB FS`` or ``dB SPL``.
220

221
        Returns
222
        -------
223
        Literal["FS", "SPL_instrument", "SPL_parameter"]:
224

225
            ``"FS"``: The values are expressed in ``dB FS``.
226

227
            ``"SPL_instrument"``: The values are expressed in ``dB SPL`` relative to the
228
            linked ``AudioData.instrument.P_REF`` property.
229

230
            ``"SPL_parameter"``: The values are expressed in ``dB SPL`` relative to the
231
            ``self._db_ref`` field.
232

233
        """
234
        if self._db_ref is not None:
1✔
235
            return "SPL_parameter"
1✔
236
        if self.audio_data is not None and self.audio_data.instrument is not None:
1✔
237
            return "SPL_instrument"
1✔
238
        return "FS"
1✔
239

240
    @property
1✔
241
    def v_lim(self) -> tuple[float, float]:
1✔
242
        """Limits (in ``dB``) of the colormap used for plotting the spectrogram."""
243
        return self._v_lim
1✔
244

245
    @v_lim.setter
1✔
246
    def v_lim(self, v_lim: tuple[float, float] | None) -> None:
1✔
247
        if v_lim is None:
1✔
248
            v_lim = (-120.0, 0.0) if self.db_type == "FS" else (0.0, 170.0)
1✔
249
        self._v_lim = v_lim
1✔
250

251
    def get_value(self) -> np.ndarray:
1✔
252
        """Return the Sx matrix of the spectrogram.
253

254
        The Sx matrix contains the absolute square of the STFT.
255
        """
256
        if not all(item.is_empty for item in self.items):
1✔
257
            return self._get_value_from_items(self.items)
1✔
258
        if not self.audio_data or not self.fft:
1✔
259
            msg = "SpectroData should have either items or audio_data."
1✔
260
            raise ValueError(msg)
1✔
261

262
        sx = self.fft.stft(
1✔
263
            x=self.audio_data.get_value_calibrated()[
264
                :,
265
                0,
266
            ],  # Only consider the 1st channel
267
            padding="zeros",
268
        )
269

270
        sx = self._merge_with_previous(sx)
1✔
271
        sx = self._remove_overlap_with_next(sx)
1✔
272

273
        if self.sx_dtype is float:
1✔
274
            sx = abs(sx) ** 2
1✔
275

276
        return sx
1✔
277

278
    def _merge_with_previous(self, data: np.ndarray) -> np.ndarray:
1✔
279
        if self.previous_data is None:
1✔
280
            return data
1✔
281
        olap = SpectroData.get_overlapped_bins(self.previous_data, self)
1✔
282
        return np.hstack((olap, data[:, olap.shape[1] :]))
1✔
283

284
    def _remove_overlap_with_next(self, data: np.ndarray) -> np.ndarray:
1✔
285
        if self.next_data is None:
1✔
286
            return data
1✔
287
        olap = SpectroData.get_overlapped_bins(self, self.next_data)
1✔
288
        return data[:, : -olap.shape[1]]
1✔
289

290
    def get_welch(
1✔
291
        self,
292
        nperseg: int | None = None,
293
        detrend: str | callable | False = "constant",
294
        scaling: Literal["density", "spectrum"] = "density",
295
        average: Literal["mean", "median"] = "mean",
296
        *,
297
        return_onesided: bool = True,
298
    ) -> np.ndarray:
299
        """Estimate power spectral density of the ``SpectroData`` using Welch's method.
300

301
        This method uses the ``scipy.signal.welch()`` function.
302
        The window, sample rate, overlap and mfft are taken from the
303
        ``SpectroData.fft`` property.
304

305
        Parameters
306
        ----------
307
        nperseg: int|None
308
            Length of each segment. Defaults to ``None``, but if window is ``str`` or
309
            ``tuple``, is set to ``256``, and if window is ``array_like``,
310
            is set to the length of the window.
311
        detrend: str | callable | False
312
            Specifies how to detrend each segment. If detrend is a ``str``,
313
            it is passed as the type argument to the detrend function.
314
            If it is a ``func``, it takes a segment and returns a detrended segment.
315
            If detrend is ``False``, no detrending is done.
316
            Defaults to ``'constant'``.
317
        return_onesided: bool
318
            If ``True``, return a one-sided spectrum for real data.
319
            If ``False``, return a two-sided spectrum.
320
            Defaults to ``True``, but for complex data,
321
            a two-sided spectrum is always returned.
322
        scaling: Literal["density", "spectrum"]
323
            Selects between computing the power spectral density (``'density'``) where
324
            ``Pxx`` has units of ``V**2/Hz`` and computing the squared magnitude
325
            spectrum (``'spectrum'``) where ``Pxx`` has units of ``V**2``,
326
            if ``x`` is measured in ``V`` and ``fs`` is measured in ``Hz``.
327
            Defaults to ``'density'``.
328
        average: Literal["mean", "median"]
329
            Method to use when averaging periodograms.
330
            Defaults to ``'mean'``.
331

332
        Returns
333
        -------
334
        np.ndarray
335
            Power spectral density or power spectrum of the ``SpectroData``.
336

337
        """
338
        window = self.fft.win
1✔
339
        noverlap = self.fft.hop
1✔
340
        if noverlap == window.shape[0]:
1✔
341
            noverlap //= 2
1✔
342
        nfft = self.fft.mfft
1✔
343

344
        _, sx = welch(
1✔
345
            self.audio_data.get_value_calibrated()[
346
                :,
347
                0,
348
            ],  # Only considers the 1rst channel
349
            fs=self.audio_data.sample_rate,
350
            window=window,
351
            nperseg=nperseg,
352
            noverlap=noverlap,
353
            nfft=nfft,
354
            detrend=detrend,
355
            return_onesided=return_onesided,
356
            scaling=scaling,
357
            average=average,
358
        )
359

360
        return sx
1✔
361

362
    def write_welch(
1✔
363
        self,
364
        folder: Path,
365
        px: np.ndarray | None = None,
366
        nperseg: int | None = None,
367
        detrend: str | callable | False = "constant",
368
        scaling: Literal["density", "spectrum"] = "density",
369
        average: Literal["mean", "median"] = "mean",
370
        *,
371
        return_onesided: bool = True,
372
    ) -> None:
373
        """Write the psd (welch) of the ``SpectroData`` to a ``npz`` file.
374

375
        Parameters
376
        ----------
377
        folder: pathlib.Path
378
            Folder in which to write the Spectro file.
379
        px: np.ndarray | None
380
            Welch ``px`` values. Will be computed if not provided.
381
        nperseg: int|None
382
            Length of each segment.
383
            Defaults to ``None``, but if window is ``str`` or ``tuple``,
384
            is set to ``256``, and if window is ``array_like``,
385
            is set to the length of the window.
386
        detrend: str | callable | False
387
            Specifies how to detrend each segment. If detrend is a ``str``,
388
            it is passed as the type argument to the detrend function.
389
            If it is a ``func``, it takes a segment and returns a detrended segment.
390
            If detrend is ``False``, no detrending is done.
391
            Defaults to ``'constant'``.
392
        return_onesided: bool
393
            If ``True``, return a one-sided spectrum for real data.
394
            If ``False``, return a two-sided spectrum.
395
            Defaults to ``True``, but for complex data,
396
            a two-sided spectrum is always returned.
397
        scaling: Literal["density", "spectrum"]
398
            Selects between computing the power spectral density (``'density'``) where
399
            ``Pxx`` has units of ``V**2/Hz`` and computing the squared magnitude
400
            spectrum (``'spectrum'``) where ``Pxx`` has units of ``V**2``,
401
            if ``x`` is measured in ``V`` and ``fs`` is measured in ``Hz``.
402
            Defaults to ``'density'``.
403
        average: Literal["mean", "median"]
404
            Method to use when averaging periodograms.
405
            Defaults to ``'mean'``.
406

407
        """
408
        super().create_directories(path=folder)
1✔
409
        px = (
1✔
410
            self.get_welch(
411
                nperseg=nperseg,
412
                detrend=detrend,
413
                return_onesided=return_onesided,
414
                scaling=scaling,
415
                average=average,
416
            )
417
            if px is None
418
            else px
419
        )
420
        freq = self.fft.f
1✔
421
        timestamps = (str(t) for t in (self.begin, self.end))
1✔
422
        np.savez(
1✔
423
            file=folder / f"{self}.npz",
424
            timestamps="_".join(timestamps),
425
            freq=freq,
426
            px=px,
427
        )
428

429
    def plot(
1✔
430
        self,
431
        ax: plt.Axes | None = None,
432
        sx: np.ndarray | None = None,
433
        scale: Scale | None = None,
434
    ) -> None:
435
        """Plot the spectrogram on a specific ``Axes``.
436

437
        Parameters
438
        ----------
439
        ax: plt.axes | None
440
            ``Axes`` on which the spectrogram should be plotted.
441
            Defaulted to ``SpectroData.get_default_ax()``.
442
        sx: np.ndarray | None
443
            Spectrogram ``sx`` values. Will be computed if ``None``.
444
        scale: osekit.core_api.frequecy_scale.Scale
445
            Custom frequency scale to use for plotting the spectrogram.
446

447
        """
448
        ax = ax if ax is not None else SpectroData.get_default_ax()
1✔
449
        sx = self.get_value() if sx is None else sx
1✔
450

451
        sx = self.to_db(sx)
1✔
452

453
        time = pd.date_range(start=self.begin, end=self.end, periods=sx.shape[1])
1✔
454
        freq = self.fft.f
1✔
455

456
        sx = sx if scale is None else scale.rescale(sx_matrix=sx, original_scale=freq)
1✔
457

458
        ax.xaxis_date()
1✔
459
        ax.imshow(
1✔
460
            sx,
461
            vmin=self._v_lim[0],
462
            vmax=self._v_lim[1],
463
            cmap=self.colormap,
464
            origin="lower",
465
            aspect="auto",
466
            interpolation="none",
467
            extent=(date2num(time[0]), date2num(time[-1]), freq[0], freq[-1]),
468
        )
469

470
    def to_db(self, sx: np.ndarray) -> np.ndarray:
1✔
471
        """Convert the ``sx`` values to ``dB``.
472

473
        If the ``self.audio_data.instrument is not None``, the values are
474
        converted to ``dB SPL`` (re ``self.audio_data.instrument.P_REF``).
475
        Otherwise, the values are converted to ``dB FS``.
476

477
        Parameters
478
        ----------
479
        sx: np.ndarray
480
            Sx values of the spectrum.
481

482
        Returns
483
        -------
484
        np.ndarray
485
            Converted Sx values.
486

487
        """
488
        if self.sx_dtype is complex:
1✔
489
            sx = abs(sx) ** 2
1✔
490

491
        # sx has already been squared up, hence the 10*log for sx and 20*log for the ref
492
        return 10 * np.log10(sx + np.nextafter(0, 1)) - 20 * np.log10(self.db_ref)
1✔
493

494
    def save_spectrogram(
1✔
495
        self,
496
        folder: Path,
497
        ax: plt.Axes | None = None,
498
        sx: np.ndarray | None = None,
499
        scale: Scale | None = None,
500
    ) -> None:
501
        """Export the spectrogram as a ``png`` image.
502

503
        Parameters
504
        ----------
505
        folder: Path
506
            Folder in which the spectrogram should be saved.
507
        ax: plt.Axes | None
508
            Axes on which the spectrogram should be plotted.
509
            Defaulted to ``SpectroData.get_default_ax()``.
510
        sx: np.ndarray | None
511
            Spectrogram ``sx`` values. Will be computed if ``None``.
512
        scale: osekit.core_api.frequecy_scale.Scale
513
            Custom frequency scale to use for plotting the spectrogram.
514

515
        """
516
        super().create_directories(path=folder)
1✔
517
        self.plot(ax=ax, sx=sx, scale=scale)
1✔
518
        plt.savefig(f"{folder / str(self)}", bbox_inches="tight", pad_inches=0)
1✔
519
        plt.close()
1✔
520
        gc.collect()
1✔
521

522
    def write(
1✔
523
        self,
524
        folder: Path,
525
        *,
526
        sx: np.ndarray | None = None,
527
        link: bool = False,
528
    ) -> None:
529
        """Write the Spectro data to file.
530

531
        Parameters
532
        ----------
533
        folder: pathlib.Path
534
            Folder in which to write the Spectro file.
535
        sx: np.ndarray | None
536
            Spectrogram ``sx`` values. Will be computed if ``None``.
537
        link: bool
538
            If ``True``, the ``SpectroData`` will be bound to the written ``npz`` file.
539
            Its items will be replaced with a single item, which will match the whole
540
            new ``SpectroFile``.
541

542
        """
543
        super().create_directories(path=folder)
1✔
544
        sx = self.get_value() if sx is None else sx
1✔
545
        time = np.arange(sx.shape[1]) * self.duration.total_seconds() / sx.shape[1]
1✔
546
        freq = self.fft.f
1✔
547
        window = self.fft.win
1✔
548
        hop = [self.fft.hop]
1✔
549
        fs = [self.fft.fs]
1✔
550
        mfft = [self.fft.mfft]
1✔
551
        db_ref = [self.db_ref]
1✔
552
        v_lim = self.v_lim
1✔
553
        timestamps = (str(t) for t in (self.begin, self.end))
1✔
554
        np.savez(
1✔
555
            file=folder / f"{self}.npz",
556
            fs=fs,
557
            time=time,
558
            freq=freq,
559
            window=window,
560
            hop=hop,
561
            sx=sx,
562
            mfft=mfft,
563
            db_ref=db_ref,
564
            v_lim=v_lim,
565
            timestamps="_".join(timestamps),
566
        )
567
        if link:
1✔
568
            self.link(folder=folder)
1✔
569

570
    def link(self, folder: Path) -> None:
1✔
571
        """Link the ``SpectroData`` to a ``SpectroFile`` in the folder.
572

573
        The given folder should contain a file named ``"str(self).npz"``.
574
        Linking is intended for ``SpectroData`` objects that have already been
575
        written to disk.
576
        After linking, the ``SpectroData`` will have a single item with the same
577
        properties of the target ``SpectroFile``.
578

579
        Parameters
580
        ----------
581
        folder: Path
582
            Folder in which is located the ``SpectroFile`` to which the ``SpectroData``
583
            instance should be linked.
584

585
        """
586
        file = SpectroFile(
1✔
587
            path=folder / f"{self}.npz",
588
            strptime_format=TIMESTAMP_FORMATS_EXPORTED_FILES,
589
        )
590
        self.items = SpectroData.from_files([file]).items
1✔
591

592
    def link_audio_data(self, audio_data: AudioData) -> None:
1✔
593
        """Link the ``SpectroData`` to a given ``AudioData``.
594

595
        Parameters
596
        ----------
597
        audio_data: AudioData
598
            The ``AudioData`` to which this ``SpectroData`` will be linked.
599

600
        """
601
        if self.begin != audio_data.begin:
1✔
602
            msg = "The begin of the audio data doesn't match."
1✔
603
            raise ValueError(msg)
1✔
604
        if self.end != audio_data.end:
1✔
605
            msg = "The end of the audio data doesn't match."
1✔
606
            raise ValueError(msg)
1✔
607
        if self.fft.fs != audio_data.sample_rate:
1✔
608
            msg = "The sample rate of the audio data doesn't match."
1✔
609
            raise ValueError(msg)
1✔
610
        self.audio_data = audio_data
1✔
611

612
    def split(
1✔
613
        self,
614
        nb_subdata: int = 2,
615
        **kwargs,  # noqa: ANN003
616
    ) -> list[SpectroData]:
617
        """Split the spectro data object in the specified number of spectro subdata.
618

619
        Parameters
620
        ----------
621
        nb_subdata: int
622
            Number of subdata in which to split the data.
623
        kwargs:
624
            None
625

626
        Returns
627
        -------
628
        list[SpectroData]
629
            The list of ``SpectroData`` subdata objects.
630

631
        """
632
        split_frames = list(
1✔
633
            np.linspace(0, self.audio_data.length, nb_subdata + 1, dtype=int),
634
        )
635
        split_frames = [
1✔
636
            self.fft.nearest_k_p(frame) if idx < (len(split_frames) - 1) else frame
637
            for idx, frame in enumerate(split_frames)
638
        ]
639

640
        ad_split = [
1✔
641
            self.audio_data.split_frames(start_frame=a, stop_frame=b)
642
            for a, b in itertools.pairwise(split_frames)
643
        ]
644
        sd_split = [
1✔
645
            SpectroData.from_audio_data(
646
                data=ad,
647
                fft=self.fft,
648
                v_lim=self.v_lim,
649
                colormap=self.colormap,
650
            )
651
            for ad in ad_split
652
        ]
653

654
        for sd1, sd2 in itertools.pairwise(sd_split):
1✔
655
            sd1.next_data = sd2
1✔
656
            sd2.previous_data = sd1
1✔
657

658
        return sd_split
1✔
659

660
    def _get_value_from_items(self, items: list[SpectroItem]) -> np.ndarray:
1✔
661
        if not all(
1✔
662
            np.array_equal(items[0].file.freq, i.file.freq)
663
            for i in items[1:]
664
            if not i.is_empty
665
        ):
666
            msg = "Items don't have the same frequency bins."
1✔
667
            raise ValueError(msg)
1✔
668

669
        if len({i.file.get_fft().delta_t for i in items if not i.is_empty}) > 1:
1✔
670
            msg = "Items don't have the same time resolution."
1✔
671
            raise ValueError(msg)
1✔
672

673
        return np.hstack(
1✔
674
            [item.get_value(fft=self.fft, sx_dtype=self.sx_dtype) for item in items],
675
        )
676

677
    @classmethod
1✔
678
    def get_overlapped_bins(cls, sd1: SpectroData, sd2: SpectroData) -> np.ndarray:
1✔
679
        """Compute the bins that overflow between the two spectro data.
680

681
        The idea is that if there is a ``SpectroData`` ``sd2`` that follows ``sd1``,
682
        ``sd1.get_value()`` will return the bins up to the first overlapping bin,
683
        and ``sd2`` will return the bins from the first overlapping bin.
684

685
        Signal processing guys might want to burn my house to the ground for it,
686
        but it seems to effectively resolve the issue we have with visible junction
687
        between spectrogram zoomed parts.
688

689
        Parameters
690
        ----------
691
        sd1: SpectroData
692
            The spectro data that ends before ``sd2``.
693
        sd2: SpectroData
694
            The spectro data that starts after ``sd1``.
695

696
        Returns
697
        -------
698
        np.ndarray:
699
            The overlapped bins.
700
            If there are ``p`` bins, ``sd1`` and ``sd2`` values should be concatenated as:
701
            ``np.hstack(sd1[:,:-p], result, sd2[:,p:])``
702

703
        """
704
        fft = sd1.fft
1✔
705
        sd1_ub = fft.upper_border_begin(sd1.audio_data.shape[0])
1✔
706
        sd1_bin_start = fft.nearest_k_p(k=sd1_ub[0], left=True)
1✔
707
        sd2_lb = fft.lower_border_end
1✔
708
        sd2_bin_stop = fft.nearest_k_p(k=sd2_lb[0], left=False)
1✔
709

710
        ad1 = sd1.audio_data.split_frames(start_frame=sd1_bin_start)
1✔
711
        ad2 = sd2.audio_data.split_frames(stop_frame=sd2_bin_stop)
1✔
712

713
        sd_part1 = SpectroData.from_audio_data(ad1, fft=fft).get_value()
1✔
714
        sd_part2 = SpectroData.from_audio_data(ad2, fft=fft).get_value()
1✔
715

716
        p1_le = fft.lower_border_end[1] - fft.p_min
1✔
717
        return sd_part1[:, -p1_le:] + sd_part2[:, :p1_le]
1✔
718

719
    @classmethod
1✔
720
    def _make_file(cls, path: Path, begin: Timestamp) -> SpectroFile:
1✔
721
        """Make a ``SpectroFile`` from a ``path`` and a ``begin`` timestamp.
722

723
        Parameters
724
        ----------
725
        path: Path
726
            Path to the file.
727
        begin: Timestamp
728
            Begin of the file.
729

730
        Returns
731
        -------
732
        SpectroFile:
733
        The ``SpectroFile`` instance.
734

735
        """
736
        return SpectroFile(path=path, begin=begin)
1✔
737

738
    @classmethod
1✔
739
    def _make_item(
1✔
740
        cls,
741
        file: TFile | None = None,
742
        begin: Timestamp | None = None,
743
        end: Timestamp | None = None,
744
    ) -> SpectroItem:
745
        """Make a ``SpectroItem`` for a given ``SpectroFile`` between ``begin`` and ``end`` timestamps.
746

747
        Parameters
748
        ----------
749
        file: SpectroFile
750
            ``SpectroFile`` of the item.
751
        begin: Timestamp
752
            Begin of the item.
753
        end:
754
            End of the item.
755

756
        Returns
757
        -------
758
        A ``SpectroItem`` for the ``SpectroFile``,
759
        between the ``begin`` and ``end`` timestamps.
760

761
        """
762
        return SpectroItem(
1✔
763
            file=file,
764
            begin=begin,
765
            end=end,
766
        )
767

768
    @classmethod
1✔
769
    def _from_base_dict(
1✔
770
        cls,
771
        dictionary: dict,
772
        files: list[SpectroFile],
773
        begin: Timestamp,
774
        end: Timestamp,
775
        **kwargs,  # noqa: ANN003
776
    ) -> SpectroData:
777
        """Deserialize the ``SpectroData``-specific parts of a Data dictionary.
778

779
        This method is called within the ``BaseData.from_dict()`` method, which
780
        deserializes the base ``files``, ``begin`` and ``end`` parameters.
781

782
        Parameters
783
        ----------
784
        dictionary: dict
785
            The serialized dictionary representing the ``SpectroData``.
786
        files: list[SpectroFile]
787
            The list of deserialized ``SpectroFiles``.
788
        begin: Timestamp
789
            The deserialized begin timestamp.
790
        end: Timestamp
791
            The deserialized end timestamp.
792
        kwargs:
793
            None
794

795
        Returns
796
        -------
797
        SpectroData
798
            The deserialized ``SpectroData``.
799

800
        """
801
        return cls.from_files(
1✔
802
            files=files,
803
            begin=begin,
804
            end=end,
805
            colormap=dictionary["colormap"],
806
        )
807

808
    def _make_split_data(
809
        self,
810
        files: list[TFile],
811
        begin: Timestamp,
812
        end: Timestamp,
813
        **kwargs,  # noqa: ANN003
814
    ) -> SpectroData: ...
815

816
    @classmethod
1✔
817
    def from_files(
1✔
818
        cls,
819
        files: list[SpectroFile],
820
        begin: Timestamp | None = None,
821
        end: Timestamp | None = None,
822
        name: str | None = None,
823
        **kwargs,  # noqa: ANN003
824
    ) -> SpectroData:
825
        """Return a ``SpectroData`` object from a list of ``SpectroFiles``.
826

827
        Parameters
828
        ----------
829
        files: list[SpectroFile]
830
            List of ``SpectroFiles`` containing the data.
831
        begin: Timestamp | None
832
            Begin of the data object.
833
            Defaulted to the begin of the first file.
834
        end: Timestamp | None
835
            End of the data object.
836
            Defaulted to the end of the last file.
837
        name: str | None
838
            Name of the exported files.
839
        kwargs
840
            Keyword arguments that are passed to the ``cls`` constructor.
841

842
            colormap: str
843
            Colormap to use for plotting the spectrogram.
844

845
        Returns
846
        -------
847
        SpectroData:
848
            The ``SpectroData`` instance.
849

850
        """
851
        fft = files[0].get_fft()
1✔
852
        db_ref = next((f.db_ref for f in files if f.db_ref is not None), None)
1✔
853
        v_lim = next((f.v_lim for f in files if f.v_lim is not None), None)
1✔
854
        instance = super().from_files(
1✔
855
            files=files,  # This way, this static error doesn't appear to the user
856
            begin=begin,
857
            end=end,
858
            name=name,
859
            fft=fft,
860
            db_ref=db_ref,
861
            v_lim=v_lim,
862
            **kwargs,
863
        )
864
        if not any(file.sx_dtype is complex for file in files):
1✔
865
            instance.sx_dtype = float
1✔
866
        return instance
1✔
867

868
    @classmethod
1✔
869
    def from_audio_data(
1✔
870
        cls,
871
        data: AudioData,
872
        fft: ShortTimeFFT,
873
        v_lim: tuple[float, float] | None = None,
874
        colormap: str | None = None,
875
    ) -> SpectroData:
876
        """Instantiate a ``SpectroData`` object from a ``AudioData`` object.
877

878
        Parameters
879
        ----------
880
        data: AudioData
881
            ``AudioData`` from which the ``SpectroData`` should be computed.
882
        fft: ShortTimeFFT
883
            The ``ShortTimeFFT`` used to compute the spectrogram.
884
        v_lim: tuple[float,float]
885
            Lower and upper limits (in ``dB``) of the colormap used
886
            for plotting the spectrogram.
887
        colormap: str
888
            Colormap to use for plotting the spectrogram.
889

890
        Returns
891
        -------
892
        SpectroData:
893
            The ``SpectroData`` object.
894

895
        """
896
        return cls(
1✔
897
            audio_data=data,
898
            fft=fft,
899
            begin=data.begin,
900
            end=data.end,
901
            v_lim=v_lim,
902
            colormap=colormap,
903
        )
904

905
    def to_dict(self, *, embed_sft: bool = True) -> dict:
1✔
906
        """Serialize a ``SpectroData`` to a dictionary.
907

908
        Parameters
909
        ----------
910
        embed_sft: bool
911
            If ``True``, the SFT parameters will be included in the dictionary.
912
            In a case where multiple ``SpectroData`` that
913
            share a same SFT are serialized, SFT parameters shouldn't be included
914
            in the dictionary, as the window values might lead to large redundant data.
915
            Rather, the SFT parameters should be serialized in
916
            a ``SpectroDataset`` dictionary so that it can be only stored once
917
            for all ``SpectroData`` instances.
918

919
        Returns
920
        -------
921
        dict:
922
            The serialized dictionary representing the ``SpectroData``.
923

924

925
        """
926
        base_dict = super().to_dict()
1✔
927
        audio_dict = {
1✔
928
            "audio_data": (
929
                None if self.audio_data is None else self.audio_data.to_dict()
930
            ),
931
        }
932
        sft_dict = {
1✔
933
            "sft": (
934
                {
935
                    "win": list(self.fft.win),
936
                    "hop": self.fft.hop,
937
                    "fs": self.fft.fs,
938
                    "mfft": self.fft.mfft,
939
                    "scale_to": self.fft.scaling,
940
                }
941
                if embed_sft
942
                else None
943
            ),
944
        }
945
        return (
1✔
946
            base_dict
947
            | audio_dict
948
            | sft_dict
949
            | {"v_lim": self.v_lim, "colormap": self.colormap}
950
        )
951

952
    @classmethod
1✔
953
    def from_dict(
1✔
954
        cls,
955
        dictionary: dict,
956
        sft: ShortTimeFFT | None = None,
957
    ) -> Self:
958
        """Deserialize a ``SpectroData`` from a dictionary.
959

960
        Parameters
961
        ----------
962
        dictionary: dict
963
            The serialized dictionary representing the ``AudioData``.
964
        sft: ShortTimeFFT | None
965
            The ``ShortTimeFFT`` used to compute the spectrogram.
966
            If not provided, the SFT parameters must be included in the dictionary.
967

968
        Returns
969
        -------
970
        SpectroData
971
            The deserialized ``SpectroData``.
972

973
        """
974
        if dictionary["audio_data"] is None:
1✔
975
            return super().from_dict(
1✔
976
                dictionary=dictionary,
977
                colormap=dictionary["colormap"],
978
            )
979

980
        if sft is None and dictionary["sft"] is None:
1✔
UNCOV
981
            msg = "Missing SFT"
×
UNCOV
982
            raise ValueError(msg)
×
983
        if sft is None:
1✔
984
            dictionary["sft"]["win"] = np.array(dictionary["sft"]["win"])
1✔
985
            sft = ShortTimeFFT(**dictionary["sft"])
1✔
986

987
        audio_data = AudioData.from_dict(dictionary["audio_data"])
1✔
988
        v_lim = (
1✔
989
            None if type(dictionary["v_lim"]) is object else tuple(dictionary["v_lim"])
990
        )
991
        spectro_data = cls.from_audio_data(
1✔
992
            audio_data,
993
            sft,
994
            v_lim=v_lim,
995
            colormap=dictionary["colormap"],
996
        )
997

998
        if dictionary["files"]:
1✔
999
            spectro_files = [
1✔
1000
                SpectroFile.from_dict(sf) for sf in dictionary["files"].values()
1001
            ]
1002
            spectro_data.items = SpectroData.from_files(spectro_files).items
1✔
1003

1004
        return spectro_data
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc