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.
- bioaccx/__init__.py +6 -0
- bioaccx/__main__.py +10 -0
- bioaccx/_click.py +49 -0
- bioaccx/_legacy_cli.py +166 -0
- bioaccx/arbimon.py +228 -0
- bioaccx/audio.py +142 -0
- bioaccx/audio_mixup.py +260 -0
- bioaccx/cli.py +638 -0
- bioaccx/config.py +613 -0
- bioaccx/convert_head.py +501 -0
- bioaccx/dataset.py +2079 -0
- bioaccx/embedder.py +374 -0
- bioaccx/embeddings_sqlite.py +88 -0
- bioaccx/exporters/__init__.py +4 -0
- bioaccx/exporters/onnx_exporter.py +360 -0
- bioaccx/exporters/tflite_exporter.py +308 -0
- bioaccx/extract_head.py +471 -0
- bioaccx/gui/__init__.py +13 -0
- bioaccx/gui/audio.py +143 -0
- bioaccx/gui/configio.py +145 -0
- bioaccx/gui/i18n.py +98 -0
- bioaccx/gui/jobs.py +271 -0
- bioaccx/gui/locales/en.json +207 -0
- bioaccx/gui/locales/pt-BR.json +752 -0
- bioaccx/gui/results.py +497 -0
- bioaccx/gui/schema.py +318 -0
- bioaccx/gui/server.py +607 -0
- bioaccx/gui/web/app.js +638 -0
- bioaccx/gui/web/explorer.js +627 -0
- bioaccx/gui/web/form.js +612 -0
- bioaccx/gui/web/i18n.js +97 -0
- bioaccx/gui/web/index.html +201 -0
- bioaccx/gui/web/styles.css +794 -0
- bioaccx/gui/web/theme.js +66 -0
- bioaccx/inat.py +121 -0
- bioaccx/registry.py +215 -0
- bioaccx/report.py +1030 -0
- bioaccx/ssh.py +192 -0
- bioaccx/train.py +1521 -0
- bioaccx/trainers/__init__.py +4 -0
- bioaccx/trainers/grouped.py +257 -0
- bioaccx/trainers/keras_trainer.py +461 -0
- bioaccx/trainers/sklearn_trainer.py +53 -0
- bioaccx/umap.py +405 -0
- bioaccx/xc.py +171 -0
- bioaccx-0.1.0.dist-info/METADATA +229 -0
- bioaccx-0.1.0.dist-info/RECORD +50 -0
- bioaccx-0.1.0.dist-info/WHEEL +4 -0
- bioaccx-0.1.0.dist-info/entry_points.txt +2 -0
- bioaccx-0.1.0.dist-info/licenses/LICENSE +21 -0
bioaccx/__init__.py
ADDED
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)
|