bioaccx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. bioaccx/__init__.py +6 -0
  2. bioaccx/__main__.py +10 -0
  3. bioaccx/_click.py +49 -0
  4. bioaccx/_legacy_cli.py +166 -0
  5. bioaccx/arbimon.py +228 -0
  6. bioaccx/audio.py +142 -0
  7. bioaccx/audio_mixup.py +260 -0
  8. bioaccx/cli.py +638 -0
  9. bioaccx/config.py +613 -0
  10. bioaccx/convert_head.py +501 -0
  11. bioaccx/dataset.py +2079 -0
  12. bioaccx/embedder.py +374 -0
  13. bioaccx/embeddings_sqlite.py +88 -0
  14. bioaccx/exporters/__init__.py +4 -0
  15. bioaccx/exporters/onnx_exporter.py +360 -0
  16. bioaccx/exporters/tflite_exporter.py +308 -0
  17. bioaccx/extract_head.py +471 -0
  18. bioaccx/gui/__init__.py +13 -0
  19. bioaccx/gui/audio.py +143 -0
  20. bioaccx/gui/configio.py +145 -0
  21. bioaccx/gui/i18n.py +98 -0
  22. bioaccx/gui/jobs.py +271 -0
  23. bioaccx/gui/locales/en.json +207 -0
  24. bioaccx/gui/locales/pt-BR.json +752 -0
  25. bioaccx/gui/results.py +497 -0
  26. bioaccx/gui/schema.py +318 -0
  27. bioaccx/gui/server.py +607 -0
  28. bioaccx/gui/web/app.js +638 -0
  29. bioaccx/gui/web/explorer.js +627 -0
  30. bioaccx/gui/web/form.js +612 -0
  31. bioaccx/gui/web/i18n.js +97 -0
  32. bioaccx/gui/web/index.html +201 -0
  33. bioaccx/gui/web/styles.css +794 -0
  34. bioaccx/gui/web/theme.js +66 -0
  35. bioaccx/inat.py +121 -0
  36. bioaccx/registry.py +215 -0
  37. bioaccx/report.py +1030 -0
  38. bioaccx/ssh.py +192 -0
  39. bioaccx/train.py +1521 -0
  40. bioaccx/trainers/__init__.py +4 -0
  41. bioaccx/trainers/grouped.py +257 -0
  42. bioaccx/trainers/keras_trainer.py +461 -0
  43. bioaccx/trainers/sklearn_trainer.py +53 -0
  44. bioaccx/umap.py +405 -0
  45. bioaccx/xc.py +171 -0
  46. bioaccx-0.1.0.dist-info/METADATA +229 -0
  47. bioaccx-0.1.0.dist-info/RECORD +50 -0
  48. bioaccx-0.1.0.dist-info/WHEEL +4 -0
  49. bioaccx-0.1.0.dist-info/entry_points.txt +2 -0
  50. bioaccx-0.1.0.dist-info/licenses/LICENSE +21 -0
bioaccx/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """bioaccx — bioacoustic custom classifier generator."""
2
+ from bioaccx.config import BioaccxConfig, load_config
3
+ from bioaccx.train import run
4
+
5
+ __all__ = ["BioaccxConfig", "load_config", "run"]
6
+ __version__ = "0.1.0"
bioaccx/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ import signal
2
+
3
+ from bioaccx.cli import main
4
+
5
+ # The GUI cancels a run on Windows with Ctrl+Break (see gui/jobs.py), which
6
+ # would otherwise end the process without the clean "Interrupted." exit.
7
+ if hasattr(signal, "SIGBREAK"):
8
+ signal.signal(signal.SIGBREAK, signal.default_int_handler)
9
+
10
+ main()
bioaccx/_click.py ADDED
@@ -0,0 +1,49 @@
1
+ """The Click that Typer drives.
2
+
3
+ Typer 0.26 vendored Click into ``typer._click`` and dropped its dependency on
4
+ the standalone package. A command built by ``typer.main.get_command`` therefore
5
+ raises ``typer._click`` exceptions — which ``except click.UsageError`` does not
6
+ catch when ``click`` is the separately installed one — and its parameters are
7
+ not instances of ``click.Argument``. Both were silent behaviour changes: unknown
8
+ options started printing a traceback instead of a usage error.
9
+
10
+ So the CLI and the docs generator import their Click from here, and this module
11
+ resolves whichever copy Typer is actually using. The fallback keeps working on
12
+ Typer < 0.26, which drove the standalone package.
13
+
14
+ Only the names bioaccx needs are re-exported. There is deliberately no ``Group``
15
+ or ``Argument``: the vendored copy has neither (Typer supplies ``TyperGroup``
16
+ and ``TyperArgument``), so a group is recognised by having ``.commands`` and an
17
+ argument by ``param.param_type_name == "argument"``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ try: # Typer >= 0.26 — the vendored copy, and the only one Typer raises from.
23
+ from typer import Abort as Abort
24
+ from typer import Exit as Exit
25
+ from typer._click.core import Command as Command
26
+ from typer._click.core import Context as Context
27
+ from typer._click.core import Parameter as Parameter
28
+ from typer._click.exceptions import ClickException as ClickException
29
+ from typer._click.exceptions import UsageError as UsageError
30
+ from typer._click.globals import get_current_context as get_current_context
31
+ except ImportError: # pragma: no cover - Typer < 0.26
32
+ from click.core import Command as Command
33
+ from click.core import Context as Context
34
+ from click.core import Parameter as Parameter
35
+ from click.exceptions import Abort as Abort
36
+ from click.exceptions import ClickException as ClickException
37
+ from click.exceptions import Exit as Exit
38
+ from click.exceptions import UsageError as UsageError
39
+ from click.globals import get_current_context as get_current_context
40
+
41
+
42
+ def is_group(command: Command) -> bool:
43
+ """True when *command* holds subcommands."""
44
+ return hasattr(command, "commands")
45
+
46
+
47
+ def is_argument(param: Parameter) -> bool:
48
+ """True for a positional argument, false for an option."""
49
+ return param.param_type_name == "argument"
bioaccx/_legacy_cli.py ADDED
@@ -0,0 +1,166 @@
1
+ """Translation of the pre-subcommand flag interface into subcommand argv.
2
+
3
+ Before subcommands, every mode was a top-level flag on one command
4
+ (``bioaccx cfg.yaml --dataset --no-split``). Scripts and documentation in the
5
+ wild still use that form, so it keeps working: :func:`is_legacy` recognises it
6
+ and :func:`translate` rewrites it into the equivalent subcommand invocation,
7
+ which :mod:`bioaccx.cli` then dispatches normally.
8
+
9
+ Everything here exists only to serve those older invocations — including the
10
+ cross-flag validation that subcommands make unnecessary, since a subcommand
11
+ simply cannot be handed a flag belonging to another mode. When the legacy
12
+ form is eventually dropped, this whole module goes with it and nothing in
13
+ ``cli.py`` needs untangling.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+
19
+ #: Subcommand names, so a first argument naming one is never treated as legacy.
20
+ SUBCOMMANDS = frozenset({
21
+ "train", "validate", "dataset", "embeddings",
22
+ "merge", "extract-head", "convert-head", "registry", "gui",
23
+ })
24
+
25
+ #: Flags that only ever existed on the old interface. Their presence marks an
26
+ #: invocation as legacy even when it starts with an option rather than a path.
27
+ _LEGACY_FLAGS = frozenset({
28
+ "--validate", "--dataset", "--embeddings", "--registry",
29
+ "--convert-head", "--convert_head",
30
+ "--merge", "--extract-head", "--extract_head",
31
+ "--no-split", "--backbone", "--embed-dim",
32
+ })
33
+
34
+
35
+ def is_legacy(argv: list[str]) -> bool:
36
+ """True when *argv* uses the old flag interface rather than a subcommand.
37
+
38
+ A first argument that is not an option decides on its own: a subcommand
39
+ name is new-style, anything else is the old bare-config form. Only when
40
+ the first argument *is* an option do the legacy flags get a say, so that
41
+ ``merge HEAD --backbone 0xbb00`` is correctly read as new-style even though
42
+ ``--backbone`` also appears in old invocations.
43
+
44
+ >>> is_legacy(["cfg.yaml"])
45
+ True
46
+ >>> is_legacy(["train", "cfg.yaml"])
47
+ False
48
+ >>> is_legacy(["merge", "head.onnx", "--backbone", "0xbb00"])
49
+ False
50
+ >>> is_legacy(["--registry"])
51
+ True
52
+ >>> is_legacy(["--help"])
53
+ False
54
+ >>> is_legacy([])
55
+ False
56
+ """
57
+ if not argv:
58
+ return False
59
+ first = argv[0]
60
+ if not first.startswith("-"):
61
+ return first not in SUBCOMMANDS
62
+ return any(arg in _LEGACY_FLAGS for arg in argv)
63
+
64
+
65
+ def _parser() -> argparse.ArgumentParser:
66
+ """The old parser, kept intact so legacy invocations fail exactly as before.
67
+
68
+ It never prints help — ``--help`` routes to the subcommand interface — so
69
+ it carries no description or epilog, only the arguments needed to read an
70
+ old command line and the errors that rejected bad combinations.
71
+ """
72
+ parser = argparse.ArgumentParser(prog="bioaccx", add_help=False)
73
+ parser.add_argument("config", nargs="?")
74
+ parser.add_argument("--registry", action="store_true")
75
+ parser.add_argument("--validate", action="store_true")
76
+ parser.add_argument("--dataset", action="store_true")
77
+ parser.add_argument("--no-split", action="store_true")
78
+ parser.add_argument("--embeddings", action="store_true")
79
+ parser.add_argument("--merge", nargs="?", const=True, metavar="HEAD")
80
+ parser.add_argument("--extract-head", "--extract_head", dest="extract_head",
81
+ nargs="?", const=True, metavar="MODEL")
82
+ parser.add_argument("--convert-head", "--convert_head", dest="convert_head",
83
+ metavar="HEAD")
84
+ parser.add_argument("-o", "--out", metavar="PATH")
85
+ parser.add_argument("--backbone", metavar="ID|PATH")
86
+ parser.add_argument("--embed-dim", type=int, metavar="N")
87
+ parser.add_argument("--labels", metavar="PATH")
88
+ parser.add_argument("--format", choices=("onnx", "tflite", "both"), default="both")
89
+ return parser
90
+
91
+
92
+ def translate(argv: list[str]) -> list[str]:
93
+ """Rewrite a legacy command line as its subcommand equivalent.
94
+
95
+ Rejects the flag combinations the old interface never allowed, with the
96
+ same messages and exit code 2, then returns argv in subcommand form.
97
+
98
+ >>> translate(["cfg.yaml"])
99
+ ['train', 'cfg.yaml']
100
+ >>> translate(["cfg.yaml", "--dataset", "--no-split"])
101
+ ['dataset', 'cfg.yaml', '--no-split']
102
+ >>> translate(["--registry"])
103
+ ['registry']
104
+ """
105
+ parser = _parser()
106
+ args = parser.parse_args(argv)
107
+
108
+ if args.no_split and not args.dataset:
109
+ parser.error("--no-split is only valid together with --dataset")
110
+
111
+ # `--merge HEAD` (a string) takes its inputs from the command line;
112
+ # a bare `--merge` (True) reads them from the config file instead.
113
+ cli_merge = isinstance(args.merge, str)
114
+ cli_extract = isinstance(args.extract_head, str)
115
+
116
+ if args.out and not (args.convert_head or cli_merge or cli_extract):
117
+ parser.error(
118
+ "-o/--out is only valid with --convert-head, or with --merge / --extract-head "
119
+ "when their inputs are given on the command line"
120
+ )
121
+ if (args.backbone or args.embed_dim or args.labels) and not (cli_merge or cli_extract):
122
+ parser.error(
123
+ "--backbone / --embed-dim / --labels are only valid with --merge <HEAD> or "
124
+ "--extract-head <MODEL>; with a config file these come from the config"
125
+ )
126
+ if args.config and (cli_merge or cli_extract):
127
+ parser.error(
128
+ "give either a config file or the command-line inputs, not both — "
129
+ "the config already carries the head/model path and the output location"
130
+ )
131
+
132
+ out = ["-o", args.out] if args.out else []
133
+
134
+ if args.convert_head:
135
+ return ["convert-head", args.convert_head, *out]
136
+ if cli_merge:
137
+ backbone = ["--backbone", args.backbone] if args.backbone else []
138
+ embed_dim = ["--embed-dim", str(args.embed_dim)] if args.embed_dim else []
139
+ return ["merge", args.merge, *backbone, *embed_dim, *out]
140
+ if cli_extract:
141
+ backbone = ["--backbone", args.backbone] if args.backbone else []
142
+ embed_dim = ["--embed-dim", str(args.embed_dim)] if args.embed_dim else []
143
+ labels = ["--labels", args.labels] if args.labels else []
144
+ return ["extract-head", args.extract_head, *backbone, *embed_dim, *labels,
145
+ "--format", args.format, *out]
146
+
147
+ if args.registry:
148
+ return ["registry"]
149
+
150
+ if args.config is None:
151
+ parser.error(
152
+ "a config file is required unless --registry / --convert-head is used, or "
153
+ "--merge / --extract-head are given their inputs directly"
154
+ )
155
+
156
+ if args.validate:
157
+ return ["validate", args.config]
158
+ if args.merge:
159
+ return ["merge", args.config]
160
+ if args.extract_head:
161
+ return ["extract-head", args.config]
162
+ if args.dataset:
163
+ return ["dataset", args.config, *(["--no-split"] if args.no_split else [])]
164
+ if args.embeddings:
165
+ return ["embeddings", args.config]
166
+ return ["train", args.config]
bioaccx/arbimon.py ADDED
@@ -0,0 +1,228 @@
1
+ """Arbimon audio fetching and caching via the rfcx SDK.
2
+
3
+ Each row in the ext_table identifies a specific 1-minute recording by:
4
+ stream_id — Arbimon recording site ID
5
+ date — local date (YYYY-MM-DD)
6
+ time — local time (HH:MM or HH:MM:SS)
7
+ utc_offset — UTC offset for the local time in hours (numeric: -3, +5.5, 0)
8
+
9
+ The label falls back to ``stream_id`` when the table row has no label.
10
+
11
+ Authentication
12
+ --------------
13
+ A persisted rfcx credentials file is required. On first use, run::
14
+
15
+ import rfcx
16
+ client = rfcx.Client()
17
+ client.authenticate(persisted_credentials_path="/path/to/.rfcx_credentials")
18
+
19
+ This opens a browser URL for device authorisation and saves the token.
20
+ Subsequent calls load the token from the file without user interaction.
21
+
22
+ Install the rfcx SDK (not on PyPI; install from the GitHub release wheel)::
23
+
24
+ pip install https://github.com/rfcx/rfcx-sdk-python/releases/download/0.3.1/rfcx-0.3.1-py3-none-any.whl
25
+
26
+ Cache layout::
27
+
28
+ <cache_dir>/
29
+ arbimon/
30
+ <stream_id>/
31
+ <files downloaded by rfcx SDK>
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import datetime
36
+ import re
37
+ from pathlib import Path
38
+ from typing import Optional
39
+
40
+
41
+ _AUDIO_EXTS: frozenset[str] = frozenset({".wav", ".flac", ".mp3", ".ogg"})
42
+ _TIMESTAMP_RE = re.compile(r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}")
43
+
44
+ # Module-level rfcx client singleton — authenticated once per credentials path.
45
+ _client = None
46
+ _authenticated_path: Optional[str] = None
47
+
48
+
49
+ def _get_client(credentials_path: Optional[str]):
50
+ """Return (and cache) a fully authenticated rfcx.Client singleton.
51
+
52
+ Re-authenticates only when a different credentials_path is passed, so
53
+ repeated calls within the same process are essentially free. The rfcx SDK
54
+ is imported lazily so that the heavy TensorFlow dependency it pulls in does
55
+ not affect users who never call Arbimon functions.
56
+ """
57
+ global _client, _authenticated_path
58
+ # Fast path: reuse the existing client if the credentials path hasn't changed.
59
+ if _client is not None and credentials_path == _authenticated_path:
60
+ return _client
61
+ try:
62
+ import rfcx
63
+ except ImportError as exc:
64
+ raise ImportError(
65
+ "The rfcx SDK is required for Arbimon downloads. Install it with:\n"
66
+ " pip install https://github.com/rfcx/rfcx-sdk-python/releases/"
67
+ "download/0.3.1/rfcx-0.3.1-py3-none-any.whl"
68
+ ) from exc
69
+ if not credentials_path:
70
+ raise ValueError(
71
+ "arbimon_credentials_path must be set to authenticate with Arbimon."
72
+ )
73
+ cred = Path(credentials_path)
74
+ if not cred.exists():
75
+ raise FileNotFoundError(
76
+ f"Arbimon credentials file not found: {credentials_path}\n"
77
+ "Run rfcx.Client().authenticate(persisted_credentials_path=...) once "
78
+ "to create it."
79
+ )
80
+ # authenticate() reads the token from disk without browser interaction when
81
+ # the credentials file already exists.
82
+ c = rfcx.Client()
83
+ c.authenticate(persisted_credentials_path=str(cred))
84
+ _client = c
85
+ _authenticated_path = credentials_path
86
+ return _client
87
+
88
+
89
+ def _parse_utc_offset(val: float | str) -> float:
90
+ """Parse a UTC offset to a float number of hours.
91
+
92
+ Accepts integers, floats, or strings such as ``"-3"``, ``"+5.5"``,
93
+ ``"UTC-3"``, ``"UTC+5:30"`` (treated as 5.5 hours).
94
+ """
95
+ if isinstance(val, (int, float)):
96
+ return float(val)
97
+ s = str(val).strip().upper().replace("UTC", "").strip()
98
+ if ":" in s:
99
+ sign = -1 if s.startswith("-") else 1
100
+ parts = s.lstrip("+-").split(":")
101
+ hours = float(parts[0])
102
+ minutes = float(parts[1]) if len(parts) > 1 else 0.0
103
+ return sign * (hours + minutes / 60.0)
104
+ return float(s)
105
+
106
+
107
+ def _to_utc(date_str: str, time_str: str, utc_offset: float) -> datetime.datetime:
108
+ """Convert a local date + time string to a naive UTC datetime.
109
+
110
+ Subtracts the UTC offset from the local time; no timezone objects are
111
+ created — the result is a plain naive datetime understood to be UTC.
112
+ """
113
+ d = datetime.date.fromisoformat(date_str.strip())
114
+ parts = time_str.strip().split(":")
115
+ h, m = int(parts[0]), int(parts[1])
116
+ s = int(parts[2]) if len(parts) > 2 else 0
117
+ local_dt = datetime.datetime(d.year, d.month, d.day, h, m, s)
118
+ return local_dt - datetime.timedelta(hours=utc_offset)
119
+
120
+
121
+ def _list_audio(directory: Path) -> set[Path]:
122
+ """Return the set of all audio files (by extension) under *directory*."""
123
+ return {p for p in directory.rglob("*") if p.suffix.lower() in _AUDIO_EXTS}
124
+
125
+
126
+ def _sentinel_path(cache_dir: Path, utc_dt: datetime.datetime) -> Path:
127
+ """Return the path of the sentinel file for a given UTC timestamp.
128
+
129
+ The sentinel stores the path of the downloaded audio file so that future
130
+ calls can locate it without listing the directory again.
131
+ """
132
+ return cache_dir / f"{utc_dt.strftime('%Y-%m-%d_%H-%M-%S')}.cached"
133
+
134
+
135
+ def _read_sentinel(cache_dir: Path, utc_dt: datetime.datetime) -> Optional[Path]:
136
+ """Return the cached audio path from a sentinel file, or None if missing/stale."""
137
+ sentinel = _sentinel_path(cache_dir, utc_dt)
138
+ if not sentinel.exists():
139
+ return None
140
+ audio_path = Path(sentinel.read_text(encoding="utf-8").strip())
141
+ if audio_path.exists():
142
+ return audio_path
143
+ sentinel.unlink() # stale — audio was deleted
144
+ return None
145
+
146
+
147
+ def _write_sentinel(cache_dir: Path, utc_dt: datetime.datetime, audio_path: Path) -> None:
148
+ """Persist the path of the downloaded audio file in the sentinel file."""
149
+ _sentinel_path(cache_dir, utc_dt).write_text(str(audio_path), encoding="utf-8")
150
+
151
+
152
+ def get_audio(
153
+ stream_id: str,
154
+ date_str: str,
155
+ time_str: str,
156
+ utc_offset: float | str,
157
+ cache_dir: Path,
158
+ credentials_path: Optional[str] = None,
159
+ ) -> tuple[Path, str]:
160
+ """Return ``(local_audio_path, stream_id)`` for an Arbimon recording.
161
+
162
+ Downloads and caches the 1-minute segment that starts at the given local
163
+ date/time. Subsequent calls with the same stream/time read from the cache
164
+ without any network access.
165
+
166
+ Parameters
167
+ ----------
168
+ stream_id:
169
+ Arbimon stream (recording site) ID.
170
+ date_str:
171
+ Recording date in local time, ISO format: ``"YYYY-MM-DD"``.
172
+ time_str:
173
+ Recording start time in local time: ``"HH:MM"`` or ``"HH:MM:SS"``.
174
+ utc_offset:
175
+ UTC offset for the local time in hours (e.g. ``-3``, ``+5.5``).
176
+ cache_dir:
177
+ Root cache directory. Arbimon files are stored under
178
+ ``<cache_dir>/arbimon/<stream_id>/``.
179
+ credentials_path:
180
+ Path to the rfcx persisted credentials file created by
181
+ ``rfcx.Client().authenticate(persisted_credentials_path=...)``.
182
+ """
183
+ # Convert local time to UTC so the rfcx SDK receives timezone-aware values.
184
+ utc_offset_h = _parse_utc_offset(utc_offset)
185
+ utc_dt = _to_utc(date_str, time_str, utc_offset_h)
186
+
187
+ stream_cache = cache_dir / "arbimon" / stream_id
188
+ stream_cache.mkdir(parents=True, exist_ok=True)
189
+
190
+ # Return early if this exact minute was already downloaded.
191
+ cached = _read_sentinel(stream_cache, utc_dt)
192
+ if cached is not None:
193
+ return cached, stream_id
194
+
195
+ # Snapshot the directory before downloading so we can detect newly added files.
196
+ before = _list_audio(stream_cache)
197
+
198
+ client = _get_client(credentials_path)
199
+ # The rfcx API uses a [min_date, max_date) window; 1-minute recordings sit
200
+ # entirely within the requested 60-second span.
201
+ min_date = utc_dt
202
+ max_date = utc_dt + datetime.timedelta(minutes=1)
203
+ print(
204
+ f" [arbimon] downloading stream={stream_id} "
205
+ f"{utc_dt.strftime('%Y-%m-%d %H:%M:%S')} UTC …"
206
+ )
207
+ client.download_segments(
208
+ dest_path=str(stream_cache),
209
+ stream=stream_id,
210
+ min_date=min_date,
211
+ max_date=max_date,
212
+ parallel=False,
213
+ )
214
+
215
+ # Identify which files the SDK wrote (set difference, then sort for determinism).
216
+ after = _list_audio(stream_cache)
217
+ new_files = sorted(after - before)
218
+ if not new_files:
219
+ raise RuntimeError(
220
+ f"No audio downloaded for stream={stream_id!r} at "
221
+ f"{utc_dt.strftime('%Y-%m-%d %H:%M:%S')} UTC. "
222
+ "Verify the stream ID, time window, and that your account has access."
223
+ )
224
+
225
+ # Take the first (alphabetically earliest) newly downloaded file and cache it.
226
+ result = new_files[0]
227
+ _write_sentinel(stream_cache, utc_dt, result)
228
+ return result, stream_id
bioaccx/audio.py ADDED
@@ -0,0 +1,142 @@
1
+ """Audio I/O and preprocessing utilities."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import soundfile as sf
8
+ from scipy.signal import butter, resample_poly, sosfilt
9
+
10
+
11
+ def load_mono(
12
+ path: Path,
13
+ target_sr: int,
14
+ offset: float = 0.0,
15
+ duration: float | None = None,
16
+ ) -> np.ndarray:
17
+ """Load audio as float32 mono at *target_sr*, optionally reading a segment.
18
+
19
+ Converts multi-channel files to mono by averaging channels. Resamples
20
+ using a polyphase filter (resample_poly) when the file's native sample rate
21
+ differs from *target_sr*; the GCD-reduced up/down ratio minimises the
22
+ filter size.
23
+
24
+ Parameters
25
+ ----------
26
+ path:
27
+ Audio file readable by soundfile (WAV, FLAC, OGG, etc.).
28
+ target_sr:
29
+ Desired output sample rate in Hz.
30
+ offset:
31
+ Start of the segment in seconds (0 = beginning of file).
32
+ duration:
33
+ Length of the segment in seconds; None reads to end of file.
34
+ """
35
+ # sf.info() is called before sf.read() to convert time-based offsets to
36
+ # frame counts, which is the only unit soundfile's read() accepts.
37
+ start_frame = int(offset * sf.info(str(path)).samplerate) if offset > 0 else 0
38
+ frames = (
39
+ int(duration * sf.info(str(path)).samplerate) if duration is not None else -1
40
+ )
41
+ audio, sr = sf.read(
42
+ str(path),
43
+ start=start_frame,
44
+ frames=frames if frames > 0 else -1,
45
+ always_2d=False,
46
+ )
47
+ # Collapse stereo/multi-channel by averaging; no-op for mono arrays.
48
+ if audio.ndim > 1:
49
+ audio = audio.mean(axis=1)
50
+ # Resample only when necessary; reducing the ratio by GCD avoids unnecessarily
51
+ # large polyphase filter orders.
52
+ if sr != target_sr:
53
+ gcd = np.gcd(target_sr, sr)
54
+ audio = resample_poly(audio, target_sr // gcd, sr // gcd)
55
+ return audio.astype(np.float32)
56
+
57
+
58
+ def to_fixed_length(audio: np.ndarray, n_samples: int) -> np.ndarray:
59
+ """Trim or zero-pad *audio* to exactly *n_samples*."""
60
+ if len(audio) >= n_samples:
61
+ return audio[:n_samples]
62
+ return np.pad(audio, (0, n_samples - len(audio))).astype(np.float32)
63
+
64
+
65
+ AUDIO_EXTENSIONS: frozenset[str] = frozenset(
66
+ {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".aiff"}
67
+ )
68
+
69
+
70
+ def apply_filter(
71
+ audio: np.ndarray,
72
+ sr: int,
73
+ filter_type: str,
74
+ freq: float | list[float],
75
+ order: int = 5,
76
+ ) -> np.ndarray:
77
+ """Apply a zero-phase Butterworth filter.
78
+
79
+ filter_type: 'hpf' | 'lpf' | 'bpf'
80
+ freq: cutoff Hz (scalar) for hpf/lpf; [low_hz, high_hz] for bpf
81
+ """
82
+ nyq = sr / 2.0
83
+ if filter_type in ("hpf", "lpf"):
84
+ Wn = float(freq) / nyq # type: ignore[arg-type]
85
+ btype = "high" if filter_type == "hpf" else "low"
86
+ sos = butter(order, Wn, btype=btype, output="sos")
87
+ elif filter_type == "bpf":
88
+ if not hasattr(freq, "__len__") or len(freq) != 2: # type: ignore[arg-type]
89
+ raise ValueError("bpf requires filter_freq as [low_hz, high_hz]")
90
+ Wn = [float(freq[0]) / nyq, float(freq[1]) / nyq] # type: ignore[index]
91
+ sos = butter(order, Wn, btype="band", output="sos")
92
+ else:
93
+ raise ValueError(f"Unknown filter_type {filter_type!r}; use 'hpf', 'lpf', or 'bpf'")
94
+ return sosfilt(sos, audio).astype(np.float32)
95
+
96
+
97
+ def mix_at_snr(signal: np.ndarray, noise: np.ndarray, snr_db: float) -> np.ndarray:
98
+ """Mix *signal* with *noise* at the requested power-based SNR (dB).
99
+
100
+ Scale factor derivation:
101
+ SNR_dB = 10 * log10(P_signal / P_noise_scaled)
102
+ scale = sqrt(P_signal / (P_noise * 10^(snr_db/10)))
103
+
104
+ If either signal or noise has zero power the signal is returned unchanged.
105
+ """
106
+ sig_power = float(np.mean(signal ** 2))
107
+ noise_power = float(np.mean(noise ** 2))
108
+ if sig_power == 0.0 or noise_power == 0.0:
109
+ return signal
110
+ scale = float(np.sqrt(sig_power / (noise_power * 10.0 ** (snr_db / 10.0))))
111
+ return (signal + scale * noise).astype(np.float32)
112
+
113
+
114
+ def place_in_window(audio: np.ndarray, n_samples: int, offset_samples: int = 0) -> np.ndarray:
115
+ """Place *audio* into a zero-padded window of *n_samples* at *offset_samples*.
116
+
117
+ Signal samples that extend past the end of the window are clipped.
118
+ """
119
+ out = np.zeros(n_samples, dtype=np.float32)
120
+ end = min(offset_samples + len(audio), n_samples)
121
+ sig_end = end - offset_samples
122
+ if sig_end > 0:
123
+ out[offset_samples:end] = audio[:sig_end]
124
+ return out
125
+
126
+
127
+ def apply_speed(audio: np.ndarray, speed: float) -> np.ndarray:
128
+ """Change playback speed via resampling (no pitch correction).
129
+
130
+ speed > 1.0 → shorter duration (faster); speed < 1.0 → longer (slower).
131
+
132
+ The speed ratio is converted to a rational up/down pair so that
133
+ resample_poly receives integers. limit_denominator(1000) keeps the
134
+ polyphase filter order manageable while keeping the approximation error
135
+ below 0.1% for typical speed values.
136
+ """
137
+ if speed == 1.0:
138
+ return audio
139
+ from fractions import Fraction
140
+ # Inverting speed gives the resampling ratio: output_len / input_len.
141
+ ratio = Fraction(1.0 / speed).limit_denominator(1000)
142
+ return resample_poly(audio, ratio.numerator, ratio.denominator).astype(np.float32)