diff --git a/.gitignore b/.gitignore index c598b98..80a1db0 100644 --- a/.gitignore +++ b/.gitignore @@ -110,5 +110,6 @@ experiments/* !batdetect2_notebook.ipynb !batdetect2/models/*.pth.tar !tests/data/*.wav +!tests/data/**/*.wav notebooks/lightning_logs example_data/preprocessed diff --git a/batdetect2/__init__.py b/batdetect2/__init__.py index 52e9a85..3946e17 100644 --- a/batdetect2/__init__.py +++ b/batdetect2/__init__.py @@ -1 +1,6 @@ -__version__ = '1.0.8' +import logging + +numba_logger = logging.getLogger("numba") +numba_logger.setLevel(logging.WARNING) + +__version__ = "1.0.8" diff --git a/batdetect2/train/train_utils.py b/batdetect2/train/train_utils.py index 62441a7..9b3b4eb 100644 --- a/batdetect2/train/train_utils.py +++ b/batdetect2/train/train_utils.py @@ -1,7 +1,5 @@ import glob import json -import os -import random import numpy as np diff --git a/batdetect2/types.py b/batdetect2/types.py index 2941c51..57a60b4 100644 --- a/batdetect2/types.py +++ b/batdetect2/types.py @@ -1,4 +1,5 @@ """Types used in the code base.""" + from typing import List, NamedTuple, Optional, Union import numpy as np @@ -17,7 +18,7 @@ try: - from typing import NotRequired + from typing import NotRequired # type: ignore except ImportError: from typing_extensions import NotRequired diff --git a/batdetect2/utils/audio_utils.py b/batdetect2/utils/audio_utils.py index 7c5852a..a60ea94 100644 --- a/batdetect2/utils/audio_utils.py +++ b/batdetect2/utils/audio_utils.py @@ -6,6 +6,8 @@ import numpy as np import torch +from batdetect2.detector import parameters + from . import wavfile __all__ = [ @@ -15,18 +17,42 @@ ] -def time_to_x_coords(time_in_file, sampling_rate, fft_win_length, fft_overlap): - nfft = np.floor(fft_win_length * sampling_rate) # int() uses floor - noverlap = np.floor(fft_overlap * nfft) - return (time_in_file * sampling_rate - noverlap) / (nfft - noverlap) +def time_to_x_coords( + time_in_file: float, + samplerate: float = parameters.TARGET_SAMPLERATE_HZ, + window_duration: float = parameters.FFT_WIN_LENGTH_S, + window_overlap: float = parameters.FFT_OVERLAP, +) -> float: + nfft = np.floor(window_duration * samplerate) # int() uses floor + noverlap = np.floor(window_overlap * nfft) + return (time_in_file * samplerate - noverlap) / (nfft - noverlap) + + +def x_coords_to_time( + x_pos: int, + samplerate: float = parameters.TARGET_SAMPLERATE_HZ, + window_duration: float = parameters.FFT_WIN_LENGTH_S, + window_overlap: float = parameters.FFT_OVERLAP, +) -> float: + n_fft = np.floor(window_duration * samplerate) + n_overlap = np.floor(window_overlap * n_fft) + n_step = n_fft - n_overlap + return ((x_pos * n_step) + n_overlap) / samplerate + # return (1.0 - fft_overlap) * fft_win_length * (x_pos + 0.5) # 0.5 is for center of temporal window -# NOTE this is also defined in post_process -def x_coords_to_time(x_pos, sampling_rate, fft_win_length, fft_overlap): - nfft = np.floor(fft_win_length * sampling_rate) - noverlap = np.floor(fft_overlap * nfft) - return ((x_pos * (nfft - noverlap)) + noverlap) / sampling_rate - # return (1.0 - fft_overlap) * fft_win_length * (x_pos + 0.5) # 0.5 is for center of temporal window +def x_coord_to_sample( + x_pos: int, + samplerate: float = parameters.TARGET_SAMPLERATE_HZ, + window_duration: float = parameters.FFT_WIN_LENGTH_S, + window_overlap: float = parameters.FFT_OVERLAP, + resize_factor: float = parameters.RESIZE_FACTOR, +) -> int: + n_fft = np.floor(window_duration * samplerate) + n_overlap = np.floor(window_overlap * n_fft) + n_step = n_fft - n_overlap + x_pos = int(x_pos / resize_factor) + return int((x_pos * n_step) + n_overlap) def generate_spectrogram( @@ -184,55 +210,118 @@ def load_audio( return sampling_rate, audio_raw +def compute_spectrogram_width( + length: int, + samplerate: int = parameters.TARGET_SAMPLERATE_HZ, + window_duration: float = parameters.FFT_WIN_LENGTH_S, + window_overlap: float = parameters.FFT_OVERLAP, + resize_factor: float = parameters.RESIZE_FACTOR, +) -> int: + n_fft = int(window_duration * samplerate) + n_overlap = int(window_overlap * n_fft) + n_step = n_fft - n_overlap + width = (length - n_overlap) // n_step + return int(width * resize_factor) + + def pad_audio( - audio_raw, - fs, - ms, - overlap_perc, - resize_factor, - divide_factor, - fixed_width=None, + audio: np.ndarray, + samplerate: int = parameters.TARGET_SAMPLERATE_HZ, + window_duration: float = parameters.FFT_WIN_LENGTH_S, + window_overlap: float = parameters.FFT_OVERLAP, + resize_factor: float = parameters.RESIZE_FACTOR, + divide_factor: int = parameters.SPEC_DIVIDE_FACTOR, + fixed_width: Optional[int] = None, ): - # Adds zeros to the end of the raw data so that the generated sepctrogram - # will be evenly divisible by `divide_factor` - # Also deals with very short audio clips and fixed_width during training + """Pad audio to be evenly divisible by `divide_factor`. + + This function pads the audio signal with zeros to ensure that the + generated spectrogram length will be evenly divisible by `divide_factor`. + This is important for the model to work correctly. + + This `divide_factor` comes from the model architecture as it downscales + the spectrogram by this factor, so the input must be divisible by this + integer number. + + Parameters + ---------- + audio : np.ndarray + The audio signal. + samplerate : int + The sampling rate of the audio signal. + window_size : float + The window size in seconds used for the spectrogram computation. + window_overlap : float + The overlap between windows in the spectrogram computation. + resize_factor : float + This factor is used to resize the spectrogram after the STFT + computation. Default is 0.5 which means that the spectrogram will be + reduced by half. Important to take into account for the final size of + the spectrogram. + divide_factor : int + The factor by which the spectrogram will be divided. + fixed_width : int, optional + If provided, the audio will be padded or cut so that the resulting + spectrogram width will be equal to this value. + + Returns + ------- + np.ndarray + The padded audio signal. + """ + spec_width = compute_spectrogram_width( + audio.shape[0], + samplerate=samplerate, + window_duration=window_duration, + window_overlap=window_overlap, + resize_factor=resize_factor, + ) - # This code could be clearer, clean up - nfft = int(ms * fs) - noverlap = int(overlap_perc * nfft) - step = nfft - noverlap - min_size = int(divide_factor * (1.0 / resize_factor)) - spec_width = (audio_raw.shape[0] - noverlap) // step - spec_width_rs = spec_width * resize_factor - - if fixed_width is not None and spec_width < fixed_width: - # too small - # used during training to ensure all the batches are the same size - diff = fixed_width * step + noverlap - audio_raw.shape[0] - audio_raw = np.hstack( - (audio_raw, np.zeros(diff, dtype=audio_raw.dtype)) + if fixed_width: + target_samples = x_coord_to_sample( + fixed_width, + samplerate=samplerate, + window_duration=window_duration, + window_overlap=window_overlap, + resize_factor=resize_factor, ) - elif fixed_width is not None and spec_width > fixed_width: - # too big - # used during training to ensure all the batches are the same size - diff = fixed_width * step + noverlap - audio_raw.shape[0] - audio_raw = audio_raw[:diff] - - elif ( - spec_width_rs < min_size - or (np.floor(spec_width_rs) % divide_factor) != 0 - ): - # need to be at least min_size - div_amt = np.ceil(spec_width_rs / float(divide_factor)) - div_amt = np.maximum(1, div_amt) - target_size = int(div_amt * divide_factor * (1.0 / resize_factor)) - diff = target_size * step + noverlap - audio_raw.shape[0] - audio_raw = np.hstack( - (audio_raw, np.zeros(diff, dtype=audio_raw.dtype)) - ) + if spec_width < fixed_width: + # need to be at least min_size + diff = target_samples - audio.shape[0] + return np.hstack((audio, np.zeros(diff, dtype=audio.dtype))) + + if spec_width > fixed_width: + return audio[:target_samples] - return audio_raw + return audio + + min_width = int(divide_factor / resize_factor) + + if spec_width < min_width: + target_samples = x_coord_to_sample( + min_width, + samplerate=samplerate, + window_duration=window_duration, + window_overlap=window_overlap, + resize_factor=resize_factor, + ) + diff = target_samples - audio.shape[0] + return np.hstack((audio, np.zeros(diff, dtype=audio.dtype))) + + if (spec_width % divide_factor) == 0: + return audio + + target_width = int(np.ceil(spec_width / divide_factor)) * divide_factor + target_samples = x_coord_to_sample( + target_width, + samplerate=samplerate, + window_duration=window_duration, + window_overlap=window_overlap, + resize_factor=resize_factor, + ) + diff = target_samples - audio.shape[0] + return np.hstack((audio, np.zeros(diff, dtype=audio.dtype))) def gen_mag_spectrogram(x, fs, ms, overlap_perc): @@ -247,7 +336,11 @@ def gen_mag_spectrogram(x, fs, ms, overlap_perc): # compute spec spec, _ = librosa.core.spectrum._spectrogram( - y=x, power=1, n_fft=nfft, hop_length=step, center=False + y=x, + power=1, + n_fft=nfft, + hop_length=step, + center=False, ) # remove DC component and flip vertical orientation diff --git a/batdetect2/utils/detector_utils.py b/batdetect2/utils/detector_utils.py index bb613eb..66b9b19 100644 --- a/batdetect2/utils/detector_utils.py +++ b/batdetect2/utils/detector_utils.py @@ -11,7 +11,7 @@ try: from numpy.exceptions import AxisError except ImportError: - from numpy import AxisError + from numpy import AxisError # type: ignore import batdetect2.detector.compute_features as feats import batdetect2.detector.post_process as pp @@ -759,7 +759,7 @@ def process_file( # Get original sampling rate file_samp_rate = librosa.get_samplerate(audio_file) - orig_samp_rate = file_samp_rate * config.get("time_expansion", 1) or 1 + orig_samp_rate = file_samp_rate * (config.get("time_expansion") or 1) # load audio file sampling_rate, audio_full = au.load_audio( diff --git a/pyproject.toml b/pyproject.toml index b6e3e27..23eb323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,8 @@ batdetect2 = "batdetect2.cli:cli" [tool.uv] dev-dependencies = [ + "debugpy>=1.8.8", + "hypothesis>=6.118.7", "pyright>=1.1.388", "pytest>=7.2.2", "ruff>=0.7.3", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..06f9ddc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture +def data_dir() -> Path: + dir = Path(__file__).parent / "data" + assert dir.exists() + return dir + + +@pytest.fixture +def contrib_dir(data_dir) -> Path: + dir = data_dir / "contrib" + assert dir.exists() + return dir diff --git a/tests/data/contrib/jeff37/0166_20240531_223911.wav b/tests/data/contrib/jeff37/0166_20240531_223911.wav new file mode 100755 index 0000000..e5daa02 Binary files /dev/null and b/tests/data/contrib/jeff37/0166_20240531_223911.wav differ diff --git a/tests/data/contrib/jeff37/0166_20240602_225340.wav b/tests/data/contrib/jeff37/0166_20240602_225340.wav new file mode 100755 index 0000000..eb38e14 Binary files /dev/null and b/tests/data/contrib/jeff37/0166_20240602_225340.wav differ diff --git a/tests/data/contrib/jeff37/0166_20240603_033731.wav b/tests/data/contrib/jeff37/0166_20240603_033731.wav new file mode 100755 index 0000000..1f95b5a Binary files /dev/null and b/tests/data/contrib/jeff37/0166_20240603_033731.wav differ diff --git a/tests/data/contrib/jeff37/0166_20240603_033937.wav b/tests/data/contrib/jeff37/0166_20240603_033937.wav new file mode 100755 index 0000000..24bdec0 Binary files /dev/null and b/tests/data/contrib/jeff37/0166_20240603_033937.wav differ diff --git a/tests/data/contrib/jeff37/0166_20240604_233500.wav b/tests/data/contrib/jeff37/0166_20240604_233500.wav new file mode 100755 index 0000000..9e4b67d Binary files /dev/null and b/tests/data/contrib/jeff37/0166_20240604_233500.wav differ diff --git a/tests/test_audio_utils.py b/tests/test_audio_utils.py new file mode 100644 index 0000000..1b489bc --- /dev/null +++ b/tests/test_audio_utils.py @@ -0,0 +1,136 @@ +import numpy as np +import torch +import torch.nn.functional as F +from hypothesis import given +from hypothesis import strategies as st + +from batdetect2.detector import parameters +from batdetect2.utils import audio_utils, detector_utils + + +@given(duration=st.floats(min_value=0.1, max_value=2)) +def test_can_compute_correct_spectrogram_width(duration: float): + samplerate = parameters.TARGET_SAMPLERATE_HZ + params = parameters.DEFAULT_SPECTROGRAM_PARAMETERS + + length = int(duration * samplerate) + audio = np.random.rand(length) + + spectrogram, _ = audio_utils.generate_spectrogram( + audio, + samplerate, + params, + ) + + # convert to pytorch + spectrogram = torch.from_numpy(spectrogram) + + # add batch and channel dimensions + spectrogram = spectrogram.unsqueeze(0).unsqueeze(0) + + # resize the spec + resize_factor = params["resize_factor"] + spec_op_shape = ( + int(params["spec_height"] * resize_factor), + int(spectrogram.shape[-1] * resize_factor), + ) + spectrogram = F.interpolate( + spectrogram, + size=spec_op_shape, + mode="bilinear", + align_corners=False, + ) + + expected_width = audio_utils.compute_spectrogram_width( + length, + samplerate=parameters.TARGET_SAMPLERATE_HZ, + window_duration=params["fft_win_length"], + window_overlap=params["fft_overlap"], + resize_factor=params["resize_factor"], + ) + + assert spectrogram.shape[-1] == expected_width + + +@given(duration=st.floats(min_value=0.1, max_value=2)) +def test_pad_audio_without_fixed_size(duration: float): + # Test the pad_audio function + # This function is used to pad audio with zeros to a specific length + # It is used in the generate_spectrogram function + # The function is tested with a simplepas + samplerate = parameters.TARGET_SAMPLERATE_HZ + params = parameters.DEFAULT_SPECTROGRAM_PARAMETERS + + length = int(duration * samplerate) + audio = np.random.rand(length) + + # pad the audio to be divisible by divide factor + padded_audio = audio_utils.pad_audio( + audio, + samplerate=samplerate, + window_duration=params["fft_win_length"], + window_overlap=params["fft_overlap"], + resize_factor=params["resize_factor"], + divide_factor=params["spec_divide_factor"], + ) + + # check that the padded audio is divisible by the divide factor + expected_width = audio_utils.compute_spectrogram_width( + len(padded_audio), + samplerate=parameters.TARGET_SAMPLERATE_HZ, + window_duration=params["fft_win_length"], + window_overlap=params["fft_overlap"], + resize_factor=params["resize_factor"], + ) + + assert expected_width % params["spec_divide_factor"] == 0 + + +@given(duration=st.floats(min_value=0.1, max_value=2)) +def test_computed_spectrograms_are_actually_divisible_by_the_spec_divide_factor( + duration: float, +): + samplerate = parameters.TARGET_SAMPLERATE_HZ + params = parameters.DEFAULT_SPECTROGRAM_PARAMETERS + length = int(duration * samplerate) + audio = np.random.rand(length) + _, spectrogram, _ = detector_utils.compute_spectrogram( + audio, + samplerate, + params, + torch.device("cpu"), + ) + assert spectrogram.shape[-1] % params["spec_divide_factor"] == 0 + + +@given( + duration=st.floats(min_value=0.1, max_value=2), + width=st.integers(min_value=128, max_value=1024), +) +def test_pad_audio_with_fixed_width(duration: float, width: int): + samplerate = parameters.TARGET_SAMPLERATE_HZ + params = parameters.DEFAULT_SPECTROGRAM_PARAMETERS + + length = int(duration * samplerate) + audio = np.random.rand(length) + + # pad the audio to be divisible by divide factor + padded_audio = audio_utils.pad_audio( + audio, + samplerate=samplerate, + window_duration=params["fft_win_length"], + window_overlap=params["fft_overlap"], + resize_factor=params["resize_factor"], + divide_factor=params["spec_divide_factor"], + fixed_width=width, + ) + + # check that the padded audio is divisible by the divide factor + expected_width = audio_utils.compute_spectrogram_width( + len(padded_audio), + samplerate=parameters.TARGET_SAMPLERATE_HZ, + window_duration=params["fft_win_length"], + window_overlap=params["fft_overlap"], + resize_factor=params["resize_factor"], + ) + assert expected_width == width diff --git a/tests/test_contrib.py b/tests/test_contrib.py new file mode 100644 index 0000000..b335349 --- /dev/null +++ b/tests/test_contrib.py @@ -0,0 +1,42 @@ +"""Test suite to ensure user provided files are correctly processed.""" + +from pathlib import Path + +from click.testing import CliRunner + +from batdetect2.cli import cli + +runner = CliRunner() + + +def test_files_negative_dimensions_are_not_allowed( + contrib_dir: Path, + tmp_path: Path, +): + """This test stems from issue #31. + + A user provided a set of files which which batdetect2 cli failed and + generated the following error message: + + [2272] "Error processing file!: negative dimensions are not allowed" + + This test ensures that the error message is not generated when running + batdetect2 cli with the same set of files. + """ + path = contrib_dir / "jeff37" + assert path.exists() + + results_dir = tmp_path / "results" + result = runner.invoke( + cli, + [ + "detect", + str(path), + str(results_dir), + "0.3", + ], + ) + assert result.exit_code == 0 + assert results_dir.exists() + assert len(list(results_dir.glob("*.csv"))) == 5 + assert len(list(results_dir.glob("*.json"))) == 5 diff --git a/uv.lock b/uv.lock index 9b6edb7..5aa47da 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version >= '3.12'", ] +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + [[package]] name = "audioread" version = "3.0.1" @@ -34,6 +43,8 @@ dependencies = [ [package.dependency-groups] dev = [ + { name = "debugpy" }, + { name = "hypothesis" }, { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, @@ -55,6 +66,8 @@ requires-dist = [ [package.metadata.dependency-groups] dev = [ + { name = "debugpy", specifier = ">=1.8.8" }, + { name = "hypothesis", specifier = ">=6.118.7" }, { name = "pyright", specifier = ">=1.1.388" }, { name = "pytest", specifier = ">=7.2.2" }, { name = "ruff", specifier = ">=0.7.3" }, @@ -283,6 +296,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, ] +[[package]] +name = "debugpy" +version = "1.8.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/5e/7667b95c9d7ddb25c047143a3a47685f9be2a5d3d177a85a730b22dc6e5c/debugpy-1.8.8.zip", hash = "sha256:e6355385db85cbd666be703a96ab7351bc9e6c61d694893206f8001e22aee091", size = 4928684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/79/677d71c342d5f24baf81d262c9e0c19cac3b17b4e4587c0574eaa3964ab1/debugpy-1.8.8-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:e59b1607c51b71545cb3496876544f7186a7a27c00b436a62f285603cc68d1c6", size = 2088337 }, + { url = "https://files.pythonhosted.org/packages/11/b3/4119fa89b66bcc64a3b186ea52ee7c22bccc5d1765ee890887678b0e3e76/debugpy-1.8.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6531d952b565b7cb2fbd1ef5df3d333cf160b44f37547a4e7cf73666aca5d8d", size = 3567953 }, + { url = "https://files.pythonhosted.org/packages/e8/4a/01f70b44af27c13d720446ce9bf14467c90411e90e6c6ffbb7c45845d23d/debugpy-1.8.8-cp310-cp310-win32.whl", hash = "sha256:b01f4a5e5c5fb1d34f4ccba99a20ed01eabc45a4684f4948b5db17a319dfb23f", size = 5128658 }, + { url = "https://files.pythonhosted.org/packages/2b/a5/c4210f3842db0911a49b3030bfc217e0772bfd33d7aa50996bc762e8a334/debugpy-1.8.8-cp310-cp310-win_amd64.whl", hash = "sha256:535f4fb1c024ddca5913bb0eb17880c8f24ba28aa2c225059db145ee557035e9", size = 5157545 }, + { url = "https://files.pythonhosted.org/packages/38/55/6b5596ea6d5490e17abc2896f1fbe83d31205a22629805daccd30686721c/debugpy-1.8.8-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:c399023146e40ae373753a58d1be0a98bf6397fadc737b97ad612886b53df318", size = 2187057 }, + { url = "https://files.pythonhosted.org/packages/3f/f7/c2ee07f6335c3620c1435aef2c4d3d4853f6b7fb0789aa2c52a84498ef90/debugpy-1.8.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09cc7b162586ea2171eea055985da2702b0723f6f907a423c9b2da5996ad67ba", size = 3139844 }, + { url = "https://files.pythonhosted.org/packages/0d/68/01d335338b68bdebab11de573f4631c7bf0404666ccbf474621123497702/debugpy-1.8.8-cp311-cp311-win32.whl", hash = "sha256:eea8821d998ebeb02f0625dd0d76839ddde8cbf8152ebbe289dd7acf2cdc6b98", size = 5049405 }, + { url = "https://files.pythonhosted.org/packages/22/1d/3f69460b4b8f01dace3882513de71a446eb37ee57fe2112be948fadebde8/debugpy-1.8.8-cp311-cp311-win_amd64.whl", hash = "sha256:d4483836da2a533f4b1454dffc9f668096ac0433de855f0c22cdce8c9f7e10c4", size = 5075025 }, + { url = "https://files.pythonhosted.org/packages/c2/04/8e79824c4d9100049bda056aeaf8f2765d1325a4521a87f8bb373c977236/debugpy-1.8.8-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:0cc94186340be87b9ac5a707184ec8f36547fb66636d1029ff4f1cc020e53996", size = 2514549 }, + { url = "https://files.pythonhosted.org/packages/a5/6b/c336d1eba1aedc9f654aefcdfe47ec41657d149f28ca1477c5f9009681c6/debugpy-1.8.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64674e95916e53c2e9540a056e5f489e0ad4872645399d778f7c598eacb7b7f9", size = 4229617 }, + { url = "https://files.pythonhosted.org/packages/63/9c/d9276c41e9e14164b31bcba789c87a355c091d0fc2d4e4e36a4881c9aa54/debugpy-1.8.8-cp312-cp312-win32.whl", hash = "sha256:5c6e885dbf12015aed73770f29dec7023cb310d0dc2ba8bfbeb5c8e43f80edc9", size = 5167033 }, + { url = "https://files.pythonhosted.org/packages/6d/1c/fd4bc22196b2d0defaa9f644ea4d676d0cb53b6434091b5fa2d4e49c85f2/debugpy-1.8.8-cp312-cp312-win_amd64.whl", hash = "sha256:19ffbd84e757a6ca0113574d1bf5a2298b3947320a3e9d7d8dc3377f02d9f864", size = 5209968 }, + { url = "https://files.pythonhosted.org/packages/3d/c8/7b1b654f7c21bac0e77272ee503b00f75e8acc8753efa542d4495591c741/debugpy-1.8.8-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:53709d4ec586b525724819dc6af1a7703502f7e06f34ded7157f7b1f963bb854", size = 2089581 }, + { url = "https://files.pythonhosted.org/packages/2d/87/57eb80944ce75f383946d79d9dd3ff0e0cd7c737f446be11661e3b963fbf/debugpy-1.8.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a9c013077a3a0000e83d97cf9cc9328d2b0bbb31f56b0e99ea3662d29d7a6a2", size = 3562815 }, + { url = "https://files.pythonhosted.org/packages/45/e1/23f65fbf5564cd8b3f126ab4a82c8a1a4728bdfd1b7fb0e2a856f794790e/debugpy-1.8.8-cp39-cp39-win32.whl", hash = "sha256:ffe94dd5e9a6739a75f0b85316dc185560db3e97afa6b215628d1b6a17561cb2", size = 5121656 }, + { url = "https://files.pythonhosted.org/packages/7c/f8/751ea54bb878fe965010d0492776671a7aab045937118b356027235e59ce/debugpy-1.8.8-cp39-cp39-win_amd64.whl", hash = "sha256:5c0e5a38c7f9b481bf31277d2f74d2109292179081f11108e668195ef926c0f9", size = 5175678 }, + { url = "https://files.pythonhosted.org/packages/03/99/ec2190d03df5dbd610418919bd1c3d8e6f61d0a97894e11ade6d3260cfb8/debugpy-1.8.8-py2.py3-none-any.whl", hash = "sha256:ec684553aba5b4066d4de510859922419febc710df7bba04fe9e7ef3de15d34f", size = 5157124 }, +] + [[package]] name = "decorator" version = "5.1.1" @@ -360,6 +398,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641 }, ] +[[package]] +name = "hypothesis" +version = "6.118.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/31/7cbfc717e2f529472695ab97d508a9b995f8e463a9b8a699762cdaa48ee3/hypothesis-6.118.7.tar.gz", hash = "sha256:604328f5d766a056182f54b4826f9b2d5f664f42bff68fd81b4d9d6c44b2398b", size = 410787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/4c/b87dc5c9ca9a4cc0c6828a90c2d1de6089f844e0c5badcdeac14fdb386c3/hypothesis-6.118.7-py3-none-any.whl", hash = "sha256:5fe1d80f46d81c6160ef762e4e11a61bb4eb6838a8fb7bd3c5a2542fb107bc38", size = 471912 }, +] + [[package]] name = "idna" version = "3.10" @@ -1260,6 +1312,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + [[package]] name = "soundfile" version = "0.12.1"