a3em-analysis 0.0.1__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ .venv
2
+ .env
3
+ __pycache__
4
+ .DS_Store
5
+ src/a3em/.devenv
6
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Vanderbilt University, A3EM Project Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,410 @@
1
+ Metadata-Version: 2.4
2
+ Name: a3em-analysis
3
+ Version: 0.0.1
4
+ Summary: A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets.
5
+ Project-URL: Homepage, https://github.com/vu-a3em/a3em-python-package
6
+ Project-URL: Issues, https://github.com/vu-a3em/a3em-python-package/issues
7
+ Author-email: Gabriel Barnard <gabriel.h.barnard@vanderbilt.edu>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # A3EM Package
16
+
17
+ ```python
18
+ import a3em
19
+ ```
20
+
21
+ A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets.
22
+
23
+ # `a3em.utils`
24
+
25
+ ## `preprocess`
26
+
27
+ ```python
28
+ preprocess(audio, sample_rate, normalization=0.7)
29
+ ```
30
+
31
+ Takes an audio clip as a `numpy.ndarray`, applies high-pass and low-pass filtering, and normalizes the signal.
32
+
33
+ ### Example
34
+
35
+ ```python
36
+ import librosa
37
+ from a3em.utils import preprocess
38
+
39
+ audio_path = "test.wav"
40
+ audio, sample_rate = librosa.load(audio_path)
41
+
42
+ preprocessed_audio = preprocess(audio, sample_rate)
43
+ ```
44
+
45
+ ---
46
+
47
+ ## `extract_features`
48
+
49
+ ```python
50
+ extract_features(audio, sample_rate)
51
+ ```
52
+
53
+ Takes an audio clip as a `numpy.ndarray` and extracts acoustic features from the signal.
54
+
55
+ ### Example
56
+
57
+ ```python
58
+ import librosa
59
+ from a3em.utils import preprocess, extract_features
60
+
61
+ audio_path = "test.wav"
62
+ audio, sample_rate = librosa.load(audio_path)
63
+
64
+ preprocessed_audio = preprocess(audio, sample_rate)
65
+ features = extract_features(preprocessed_audio, sample_rate)
66
+ ```
67
+
68
+ # `a3em.datasets`
69
+
70
+ The `a3em.datasets` module provides convenient access to supported A3EM datasets. Dataset classes handle downloading, preprocessing, clip extraction, and feature extraction.
71
+
72
+ ## Arden
73
+
74
+ The `Arden` dataset contains collar-borne AudioMoth recordings collected in June 2025 in Samburu National Reserve, Kenya.
75
+
76
+ ```python
77
+ from a3em.datasets import Arden
78
+ import os
79
+
80
+ dataset = Arden(
81
+ path=os.getenv("DATA_PATH"),
82
+ token=os.getenv("API_TOKEN")
83
+ )
84
+ ```
85
+
86
+ `path` specifies where the dataset should be stored locally. `token` is used to authenticate downloads from Dryad.
87
+
88
+ The first time data is loaded, the dataset will automatically:
89
+
90
+ 1. Check for the required audio and annotation files locally.
91
+ 2. Download the dataset from Dryad if necessary.
92
+ 3. Extract downloaded archives.
93
+ 4. Load and filter annotation metadata.
94
+ 5. Optionally generate background-noise examples.
95
+ 6. Extract audio clips.
96
+ 7. Preprocess the clips.
97
+ 8. Compute acoustic features.
98
+
99
+ The extracted clips and features are cached on the `Arden` instance so that subsequent calls do not repeat feature extraction unless `reload=True` is specified.
100
+
101
+ ---
102
+
103
+ ## `load_data`
104
+
105
+ ```python
106
+ load_data(
107
+ random_state=None,
108
+ sample_rate=2000,
109
+ rumble_only=False,
110
+ reload=False
111
+ )
112
+ ```
113
+
114
+ Loads the dataset and returns the extracted acoustic features together with their binary labels.
115
+
116
+ ### Example
117
+
118
+ ```python
119
+ features, labels = dataset.load_data(
120
+ random_state=123
121
+ )
122
+ ```
123
+
124
+ ### Parameters
125
+
126
+ | Parameter | Description |
127
+ | -------------- | ------------------------------------------------------------------------------------------------------------------ |
128
+ | `random_state` | Seed used when shuffling metadata and generating background-noise clips. |
129
+ | `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
130
+ | `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
131
+ | `reload` | If `True`, regenerates metadata, clips, and features even if they have already been loaded. |
132
+
133
+ ### Returns
134
+
135
+ ```text
136
+ features : pandas.DataFrame
137
+ labels : pandas.Series
138
+ ```
139
+
140
+ Labels are binary:
141
+
142
+ * `0` — Background noise
143
+ * `1` — Elephant rumble
144
+
145
+ Annotation quality values `2`, `3`, and `4` are mapped to the rumble label `1`. Background-noise examples have quality `0` and are mapped to label `0`.
146
+
147
+ ---
148
+
149
+ ## `load_data_ml`
150
+
151
+ ```python
152
+ load_data_ml(
153
+ test_split=0.2,
154
+ random_state=None,
155
+ sample_rate=2000,
156
+ rumble_only=False,
157
+ reload=False,
158
+ shuffle=False
159
+ )
160
+ ```
161
+
162
+ Loads the dataset and creates train/test splits suitable for machine-learning workflows.
163
+
164
+ Internally, this method uses `sklearn.model_selection.train_test_split`.
165
+
166
+ ### Example
167
+
168
+ ```python
169
+ x_train, x_test, y_train, y_test = dataset.load_data_ml(
170
+ test_split=0.2,
171
+ random_state=123,
172
+ shuffle=True
173
+ )
174
+ ```
175
+
176
+ ### Parameters
177
+
178
+ | Parameter | Description |
179
+ | -------------- | ------------------------------------------------------------------------------------------------------------------ |
180
+ | `test_split` | Fraction of samples reserved for testing. Defaults to `0.2`. |
181
+ | `random_state` | Seed used for dataset generation and the train/test split. |
182
+ | `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
183
+ | `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
184
+ | `reload` | If `True`, regenerates metadata, clips, and features before splitting. |
185
+ | `shuffle` | Whether samples should be shuffled by `train_test_split` before creating the split. Defaults to `False`. |
186
+
187
+ ### Returns
188
+
189
+ ```text
190
+ x_train : pandas.DataFrame
191
+ x_test : pandas.DataFrame
192
+ y_train : pandas.Series
193
+ y_test : pandas.Series
194
+ ```
195
+
196
+ ---
197
+
198
+ ## `load_clips`
199
+
200
+ ```python
201
+ load_clips(
202
+ random_state=None,
203
+ sample_rate=2000,
204
+ rumble_only=False,
205
+ reload=False
206
+ )
207
+ ```
208
+
209
+ Returns the extracted audio clips together with their computed acoustic features.
210
+
211
+ ### Example
212
+
213
+ ```python
214
+ clips, features = dataset.load_clips(
215
+ random_state=123,
216
+ rumble_only=False
217
+ )
218
+ ```
219
+
220
+ ### Parameters
221
+
222
+ | Parameter | Description |
223
+ | -------------- | -------------------------------------------------------- |
224
+ | `random_state` | Seed used when generating the dataset. |
225
+ | `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
226
+ | `rumble_only` | If `True`, background-noise examples are not generated. |
227
+ | `reload` | If `True`, regenerates the clips and features. |
228
+
229
+ ### Returns
230
+
231
+ * `clips` — list containing the extracted audio clips as NumPy arrays.
232
+ * `features` — `pandas.DataFrame` containing one row of acoustic features for each clip.
233
+
234
+ Each extracted clip includes a `0.2` second buffer before and after its annotated time range.
235
+
236
+ Clips shorter than two seconds after extraction are discarded during feature extraction.
237
+
238
+ ---
239
+
240
+ ## Iteration
241
+
242
+ An `Arden` dataset can be iterated over directly.
243
+
244
+ ```python
245
+ dataset = Arden(path, token)
246
+
247
+ for clip, features in dataset:
248
+ print(len(clip))
249
+ print(features)
250
+ ```
251
+
252
+ If the dataset has not already been loaded, iteration automatically initializes it using the default loading options.
253
+
254
+ Each iteration returns:
255
+
256
+ ```python
257
+ (
258
+ numpy.ndarray, # audio clip
259
+ dict # extracted acoustic features
260
+ )
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Indexing
266
+
267
+ Individual clips and their corresponding features can be accessed by index after the dataset has been loaded.
268
+
269
+ ```python
270
+ clip, features = dataset[10]
271
+ ```
272
+
273
+ The returned feature set is converted from its DataFrame row into a dictionary.
274
+
275
+ If the dataset has not yet been loaded, indexing returns:
276
+
277
+ ```python
278
+ (None, None)
279
+ ```
280
+
281
+ ---
282
+
283
+ ## Dataset Length
284
+
285
+ The number of metadata entries currently loaded can be obtained with `len()`:
286
+
287
+ ```python
288
+ len(dataset)
289
+ ```
290
+
291
+ Before the dataset has been initialized, its length is `0`.
292
+
293
+ ---
294
+
295
+ ## Rumble Filtering
296
+
297
+ Arden annotations are filtered before clips are extracted.
298
+
299
+ Only entries satisfying all of the following conditions are retained:
300
+
301
+ * `call_type` is `RUM` or `BKG`
302
+ * `earflap` is `0` or `1`
303
+ * `overlap` is `N`
304
+ * `quality` is `0`, `2`, `3`, or `4`
305
+ * duration is greater than `2` seconds
306
+
307
+ When `rumble_only=True`, background-noise examples are not generated.
308
+
309
+ ---
310
+
311
+ ## Background-Noise Generation
312
+
313
+ When `rumble_only=False`, background-noise (`BKG`) examples are automatically generated from regions outside the annotated event ranges.
314
+
315
+ The duration of generated noise clips is based on the mean and standard deviation of annotation durations in the corresponding recording.
316
+
317
+ Generated background-noise entries use:
318
+
319
+ ```text
320
+ call_type = BKG
321
+ quality = 0
322
+ overlap = N
323
+ earflap = 0
324
+ ```
325
+
326
+ Because background-noise selection is randomized, use `random_state` when reproducible dataset generation is required.
327
+
328
+ ```python
329
+ features, labels = dataset.load_data(
330
+ random_state=123
331
+ )
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Audio Processing
337
+
338
+ Audio recordings are loaded using `librosa` at the requested sample rate:
339
+
340
+ ```python
341
+ sample_rate=2000
342
+ ```
343
+
344
+ For each retained metadata entry:
345
+
346
+ 1. The corresponding time range is extracted from the recording.
347
+ 2. A `0.2` second buffer is added to each side.
348
+ 3. The clip is passed through `a3em.utils.preprocess`.
349
+ 4. Acoustic features are calculated using `a3em.utils.extract_features`.
350
+
351
+ The original extracted clip and its computed features are retained by the dataset instance.
352
+
353
+ ---
354
+
355
+ ## Reloading Data
356
+
357
+ Once clips and features have been generated, the `Arden` instance reuses them.
358
+
359
+ To force the dataset to regenerate its metadata, clips, and features:
360
+
361
+ ```python
362
+ features, labels = dataset.load_data(
363
+ reload=True
364
+ )
365
+ ```
366
+
367
+ This is useful when changing parameters such as:
368
+
369
+ ```python
370
+ sample_rate
371
+ rumble_only
372
+ random_state
373
+ ```
374
+
375
+ ---
376
+
377
+ ## Quick Start
378
+
379
+ ```python
380
+ import os
381
+ from a3em.datasets import Arden
382
+
383
+ dataset = Arden(
384
+ path=os.getenv("DATA_PATH"),
385
+ token=os.getenv("API_TOKEN")
386
+ )
387
+
388
+ # Load features and labels
389
+ features, labels = dataset.load_data(
390
+ random_state=123
391
+ )
392
+
393
+ print(features.head())
394
+ print(labels.head())
395
+
396
+ # Create a machine-learning split
397
+ x_train, x_test, y_train, y_test = dataset.load_data_ml(
398
+ test_split=0.2,
399
+ random_state=123,
400
+ shuffle=True
401
+ )
402
+
403
+ # Access raw clips and features
404
+ clips, clip_features = dataset.load_clips()
405
+
406
+ # Iterate through clips
407
+ for clip, feature_set in dataset:
408
+ print(clip.shape)
409
+ print(feature_set)
410
+ ```
@@ -0,0 +1,396 @@
1
+ # A3EM Package
2
+
3
+ ```python
4
+ import a3em
5
+ ```
6
+
7
+ A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets.
8
+
9
+ # `a3em.utils`
10
+
11
+ ## `preprocess`
12
+
13
+ ```python
14
+ preprocess(audio, sample_rate, normalization=0.7)
15
+ ```
16
+
17
+ Takes an audio clip as a `numpy.ndarray`, applies high-pass and low-pass filtering, and normalizes the signal.
18
+
19
+ ### Example
20
+
21
+ ```python
22
+ import librosa
23
+ from a3em.utils import preprocess
24
+
25
+ audio_path = "test.wav"
26
+ audio, sample_rate = librosa.load(audio_path)
27
+
28
+ preprocessed_audio = preprocess(audio, sample_rate)
29
+ ```
30
+
31
+ ---
32
+
33
+ ## `extract_features`
34
+
35
+ ```python
36
+ extract_features(audio, sample_rate)
37
+ ```
38
+
39
+ Takes an audio clip as a `numpy.ndarray` and extracts acoustic features from the signal.
40
+
41
+ ### Example
42
+
43
+ ```python
44
+ import librosa
45
+ from a3em.utils import preprocess, extract_features
46
+
47
+ audio_path = "test.wav"
48
+ audio, sample_rate = librosa.load(audio_path)
49
+
50
+ preprocessed_audio = preprocess(audio, sample_rate)
51
+ features = extract_features(preprocessed_audio, sample_rate)
52
+ ```
53
+
54
+ # `a3em.datasets`
55
+
56
+ The `a3em.datasets` module provides convenient access to supported A3EM datasets. Dataset classes handle downloading, preprocessing, clip extraction, and feature extraction.
57
+
58
+ ## Arden
59
+
60
+ The `Arden` dataset contains collar-borne AudioMoth recordings collected in June 2025 in Samburu National Reserve, Kenya.
61
+
62
+ ```python
63
+ from a3em.datasets import Arden
64
+ import os
65
+
66
+ dataset = Arden(
67
+ path=os.getenv("DATA_PATH"),
68
+ token=os.getenv("API_TOKEN")
69
+ )
70
+ ```
71
+
72
+ `path` specifies where the dataset should be stored locally. `token` is used to authenticate downloads from Dryad.
73
+
74
+ The first time data is loaded, the dataset will automatically:
75
+
76
+ 1. Check for the required audio and annotation files locally.
77
+ 2. Download the dataset from Dryad if necessary.
78
+ 3. Extract downloaded archives.
79
+ 4. Load and filter annotation metadata.
80
+ 5. Optionally generate background-noise examples.
81
+ 6. Extract audio clips.
82
+ 7. Preprocess the clips.
83
+ 8. Compute acoustic features.
84
+
85
+ The extracted clips and features are cached on the `Arden` instance so that subsequent calls do not repeat feature extraction unless `reload=True` is specified.
86
+
87
+ ---
88
+
89
+ ## `load_data`
90
+
91
+ ```python
92
+ load_data(
93
+ random_state=None,
94
+ sample_rate=2000,
95
+ rumble_only=False,
96
+ reload=False
97
+ )
98
+ ```
99
+
100
+ Loads the dataset and returns the extracted acoustic features together with their binary labels.
101
+
102
+ ### Example
103
+
104
+ ```python
105
+ features, labels = dataset.load_data(
106
+ random_state=123
107
+ )
108
+ ```
109
+
110
+ ### Parameters
111
+
112
+ | Parameter | Description |
113
+ | -------------- | ------------------------------------------------------------------------------------------------------------------ |
114
+ | `random_state` | Seed used when shuffling metadata and generating background-noise clips. |
115
+ | `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
116
+ | `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
117
+ | `reload` | If `True`, regenerates metadata, clips, and features even if they have already been loaded. |
118
+
119
+ ### Returns
120
+
121
+ ```text
122
+ features : pandas.DataFrame
123
+ labels : pandas.Series
124
+ ```
125
+
126
+ Labels are binary:
127
+
128
+ * `0` — Background noise
129
+ * `1` — Elephant rumble
130
+
131
+ Annotation quality values `2`, `3`, and `4` are mapped to the rumble label `1`. Background-noise examples have quality `0` and are mapped to label `0`.
132
+
133
+ ---
134
+
135
+ ## `load_data_ml`
136
+
137
+ ```python
138
+ load_data_ml(
139
+ test_split=0.2,
140
+ random_state=None,
141
+ sample_rate=2000,
142
+ rumble_only=False,
143
+ reload=False,
144
+ shuffle=False
145
+ )
146
+ ```
147
+
148
+ Loads the dataset and creates train/test splits suitable for machine-learning workflows.
149
+
150
+ Internally, this method uses `sklearn.model_selection.train_test_split`.
151
+
152
+ ### Example
153
+
154
+ ```python
155
+ x_train, x_test, y_train, y_test = dataset.load_data_ml(
156
+ test_split=0.2,
157
+ random_state=123,
158
+ shuffle=True
159
+ )
160
+ ```
161
+
162
+ ### Parameters
163
+
164
+ | Parameter | Description |
165
+ | -------------- | ------------------------------------------------------------------------------------------------------------------ |
166
+ | `test_split` | Fraction of samples reserved for testing. Defaults to `0.2`. |
167
+ | `random_state` | Seed used for dataset generation and the train/test split. |
168
+ | `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
169
+ | `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
170
+ | `reload` | If `True`, regenerates metadata, clips, and features before splitting. |
171
+ | `shuffle` | Whether samples should be shuffled by `train_test_split` before creating the split. Defaults to `False`. |
172
+
173
+ ### Returns
174
+
175
+ ```text
176
+ x_train : pandas.DataFrame
177
+ x_test : pandas.DataFrame
178
+ y_train : pandas.Series
179
+ y_test : pandas.Series
180
+ ```
181
+
182
+ ---
183
+
184
+ ## `load_clips`
185
+
186
+ ```python
187
+ load_clips(
188
+ random_state=None,
189
+ sample_rate=2000,
190
+ rumble_only=False,
191
+ reload=False
192
+ )
193
+ ```
194
+
195
+ Returns the extracted audio clips together with their computed acoustic features.
196
+
197
+ ### Example
198
+
199
+ ```python
200
+ clips, features = dataset.load_clips(
201
+ random_state=123,
202
+ rumble_only=False
203
+ )
204
+ ```
205
+
206
+ ### Parameters
207
+
208
+ | Parameter | Description |
209
+ | -------------- | -------------------------------------------------------- |
210
+ | `random_state` | Seed used when generating the dataset. |
211
+ | `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
212
+ | `rumble_only` | If `True`, background-noise examples are not generated. |
213
+ | `reload` | If `True`, regenerates the clips and features. |
214
+
215
+ ### Returns
216
+
217
+ * `clips` — list containing the extracted audio clips as NumPy arrays.
218
+ * `features` — `pandas.DataFrame` containing one row of acoustic features for each clip.
219
+
220
+ Each extracted clip includes a `0.2` second buffer before and after its annotated time range.
221
+
222
+ Clips shorter than two seconds after extraction are discarded during feature extraction.
223
+
224
+ ---
225
+
226
+ ## Iteration
227
+
228
+ An `Arden` dataset can be iterated over directly.
229
+
230
+ ```python
231
+ dataset = Arden(path, token)
232
+
233
+ for clip, features in dataset:
234
+ print(len(clip))
235
+ print(features)
236
+ ```
237
+
238
+ If the dataset has not already been loaded, iteration automatically initializes it using the default loading options.
239
+
240
+ Each iteration returns:
241
+
242
+ ```python
243
+ (
244
+ numpy.ndarray, # audio clip
245
+ dict # extracted acoustic features
246
+ )
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Indexing
252
+
253
+ Individual clips and their corresponding features can be accessed by index after the dataset has been loaded.
254
+
255
+ ```python
256
+ clip, features = dataset[10]
257
+ ```
258
+
259
+ The returned feature set is converted from its DataFrame row into a dictionary.
260
+
261
+ If the dataset has not yet been loaded, indexing returns:
262
+
263
+ ```python
264
+ (None, None)
265
+ ```
266
+
267
+ ---
268
+
269
+ ## Dataset Length
270
+
271
+ The number of metadata entries currently loaded can be obtained with `len()`:
272
+
273
+ ```python
274
+ len(dataset)
275
+ ```
276
+
277
+ Before the dataset has been initialized, its length is `0`.
278
+
279
+ ---
280
+
281
+ ## Rumble Filtering
282
+
283
+ Arden annotations are filtered before clips are extracted.
284
+
285
+ Only entries satisfying all of the following conditions are retained:
286
+
287
+ * `call_type` is `RUM` or `BKG`
288
+ * `earflap` is `0` or `1`
289
+ * `overlap` is `N`
290
+ * `quality` is `0`, `2`, `3`, or `4`
291
+ * duration is greater than `2` seconds
292
+
293
+ When `rumble_only=True`, background-noise examples are not generated.
294
+
295
+ ---
296
+
297
+ ## Background-Noise Generation
298
+
299
+ When `rumble_only=False`, background-noise (`BKG`) examples are automatically generated from regions outside the annotated event ranges.
300
+
301
+ The duration of generated noise clips is based on the mean and standard deviation of annotation durations in the corresponding recording.
302
+
303
+ Generated background-noise entries use:
304
+
305
+ ```text
306
+ call_type = BKG
307
+ quality = 0
308
+ overlap = N
309
+ earflap = 0
310
+ ```
311
+
312
+ Because background-noise selection is randomized, use `random_state` when reproducible dataset generation is required.
313
+
314
+ ```python
315
+ features, labels = dataset.load_data(
316
+ random_state=123
317
+ )
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Audio Processing
323
+
324
+ Audio recordings are loaded using `librosa` at the requested sample rate:
325
+
326
+ ```python
327
+ sample_rate=2000
328
+ ```
329
+
330
+ For each retained metadata entry:
331
+
332
+ 1. The corresponding time range is extracted from the recording.
333
+ 2. A `0.2` second buffer is added to each side.
334
+ 3. The clip is passed through `a3em.utils.preprocess`.
335
+ 4. Acoustic features are calculated using `a3em.utils.extract_features`.
336
+
337
+ The original extracted clip and its computed features are retained by the dataset instance.
338
+
339
+ ---
340
+
341
+ ## Reloading Data
342
+
343
+ Once clips and features have been generated, the `Arden` instance reuses them.
344
+
345
+ To force the dataset to regenerate its metadata, clips, and features:
346
+
347
+ ```python
348
+ features, labels = dataset.load_data(
349
+ reload=True
350
+ )
351
+ ```
352
+
353
+ This is useful when changing parameters such as:
354
+
355
+ ```python
356
+ sample_rate
357
+ rumble_only
358
+ random_state
359
+ ```
360
+
361
+ ---
362
+
363
+ ## Quick Start
364
+
365
+ ```python
366
+ import os
367
+ from a3em.datasets import Arden
368
+
369
+ dataset = Arden(
370
+ path=os.getenv("DATA_PATH"),
371
+ token=os.getenv("API_TOKEN")
372
+ )
373
+
374
+ # Load features and labels
375
+ features, labels = dataset.load_data(
376
+ random_state=123
377
+ )
378
+
379
+ print(features.head())
380
+ print(labels.head())
381
+
382
+ # Create a machine-learning split
383
+ x_train, x_test, y_train, y_test = dataset.load_data_ml(
384
+ test_split=0.2,
385
+ random_state=123,
386
+ shuffle=True
387
+ )
388
+
389
+ # Access raw clips and features
390
+ clips, clip_features = dataset.load_clips()
391
+
392
+ # Iterate through clips
393
+ for clip, feature_set in dataset:
394
+ print(clip.shape)
395
+ print(feature_set)
396
+ ```
@@ -0,0 +1,6 @@
1
+ #!/bin/bash
2
+ source .venv/bin/activate
3
+ rm -rf dist/
4
+ python -m build
5
+ python -m twine upload dist/*
6
+ deactivate
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling >= 1.26",
4
+ "pandas",
5
+ "numpy",
6
+ "librosa",
7
+ "soundfile",
8
+ "matplotlib",
9
+ "scikit-learn",
10
+ "python-dotenv",
11
+ "statsmodels",
12
+ "tqdm"
13
+ ]
14
+ build-backend = "hatchling.build"
15
+
16
+ [project]
17
+ name = "a3em-analysis"
18
+ version = "0.0.1"
19
+ authors = [
20
+ { name="Gabriel Barnard", email="gabriel.h.barnard@vanderbilt.edu" },
21
+ ]
22
+ description = "A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets."
23
+ readme = "README.md"
24
+ requires-python = ">=3.8"
25
+ classifiers = [
26
+ "Programming Language :: Python :: 3",
27
+ "Operating System :: OS Independent",
28
+ ]
29
+ license = "MIT"
30
+ license-files = ["LICENSE"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/vu-a3em/a3em-python-package"
34
+ Issues = "https://github.com/vu-a3em/a3em-python-package/issues"
@@ -0,0 +1,3 @@
1
+ build
2
+ twine
3
+ setuptools
@@ -0,0 +1,2 @@
1
+ from .utils import *
2
+ from .datasets import *
@@ -0,0 +1,350 @@
1
+ import a3em.utils
2
+ import librosa
3
+ import random
4
+ import requests
5
+ import zipfile
6
+ import numpy as np
7
+ import pandas as pd
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+ from sklearn.model_selection import train_test_split
11
+ from tqdm import tqdm
12
+
13
+ class Dataset(ABC):
14
+
15
+ def __init__(self, path, token=None):
16
+ self.path = Path(path)
17
+ self.token = token
18
+ self._index = 0
19
+
20
+ @abstractmethod
21
+ def load_data(self, test_split, random_state, shuffle):
22
+ return None
23
+
24
+ @abstractmethod
25
+ def __iter__(self):
26
+ return None
27
+
28
+ @abstractmethod
29
+ def __next__(self):
30
+ return None
31
+
32
+ @abstractmethod
33
+ def __getitem__(self, key):
34
+ return None
35
+
36
+ @abstractmethod
37
+ def __len__(self):
38
+ return None
39
+
40
+ class Arden(Dataset):
41
+
42
+ api = 'https://datadryad.org/'
43
+ doi = 'doi%3A10.5061%2Fdryad.xd2547dz3'
44
+
45
+ def __init__(self, path, token):
46
+ super().__init__(path, token)
47
+ self.prefetch = None
48
+ self.metadata = None
49
+ self.audiomoth_path = self.path / 'audiomoth'
50
+ self.annotations_path = self.path / 'manualAnnotations'
51
+ self._features = None
52
+ self._clips = None
53
+
54
+ def load_data(
55
+ self,
56
+ random_state=None,
57
+ sample_rate=2000,
58
+ rumble_only=False,
59
+ reload=False
60
+ ):
61
+ if self._clips is None or self._features is None or reload:
62
+ self.__setup(random_state, sample_rate, rumble_only)
63
+
64
+ labels = (
65
+ self.metadata['quality']
66
+ .replace({0: 0, 2: 1, 3: 1, 4: 1})
67
+ .rename('label')
68
+ )
69
+
70
+ return self._features, labels
71
+
72
+ def load_data_ml(
73
+ self,
74
+ test_split=0.2,
75
+ random_state=None,
76
+ sample_rate=2000,
77
+ rumble_only=False,
78
+ reload=False,
79
+ shuffle=False
80
+ ):
81
+ if self._clips is None or self._features is None or reload:
82
+ self.__setup(random_state, sample_rate, rumble_only)
83
+
84
+ labels = (
85
+ self.metadata['quality']
86
+ .replace({0: 0, 2: 1, 3: 1, 4: 1})
87
+ .rename('label')
88
+ )
89
+
90
+ return train_test_split(
91
+ self._features,
92
+ labels,
93
+ test_size=test_split,
94
+ random_state=random_state,
95
+ shuffle=shuffle
96
+ )
97
+
98
+ def load_clips(
99
+ self,
100
+ random_state=None,
101
+ sample_rate=2000,
102
+ rumble_only=False,
103
+ reload=False
104
+ ):
105
+ if self._clips is None or self._features is None or reload:
106
+ self.__setup(random_state, sample_rate, rumble_only)
107
+ return self._clips, self._features
108
+
109
+ def __iter__(
110
+ self,
111
+ random_state=None,
112
+ sample_rate=2000,
113
+ rumble_only=False,
114
+ reload=False
115
+ ):
116
+ if self._clips is None or self._features is None or reload:
117
+ self.__setup(random_state, sample_rate, rumble_only)
118
+ self._index = 0
119
+ return self
120
+
121
+ def __next__(self):
122
+ if self._index == len(self):
123
+ raise StopIteration
124
+ clip = self._clips[self._index]
125
+ features = self._features.iloc[self._index].to_dict()
126
+ self._index += 1
127
+ return clip, features
128
+
129
+ def __getitem__(self, key):
130
+ if self._clips is None or self._features is None:
131
+ return None, None
132
+ return self._clips[key], self._features.iloc[key].to_dict()
133
+
134
+ def __len__(self):
135
+ return 0 if self.metadata is None else len(self.metadata)
136
+
137
+ def __prefetch(self):
138
+ if not self.__validate_local_data():
139
+ self.__download_data()
140
+ audiomoth_files = sorted(self.audiomoth_path.glob('*.WAV'))
141
+ annotation_files = sorted(self.annotations_path.glob('*.txt'))
142
+ file_stems = [file.stem for file in audiomoth_files]
143
+ file_pairs = [
144
+ {'audio_path': x[0], 'annotation_path': x[1]}
145
+ for x in zip(audiomoth_files, annotation_files)
146
+ ]
147
+ self.prefetch = dict(zip(file_stems, file_pairs))
148
+ print('prefetch complete')
149
+
150
+ def __setup(self, random_state, sample_rate, rumble_only):
151
+ self.__prefetch()
152
+ self.__load_metadata(random_state, rumble_only)
153
+ self.__load_audio_features(random_state, sample_rate)
154
+
155
+ def __validate_local_data(self):
156
+ if not (self.audiomoth_path.exists() and self.annotations_path.exists()):
157
+ return False
158
+ audiomoth_contents = sorted(self.audiomoth_path.glob('*.WAV'))
159
+ annotations_contents = sorted(self.annotations_path.glob('*.txt'))
160
+ annotations_stems = [file.stem for file in annotations_contents]
161
+ audiomoth_stems = [file.stem for file in audiomoth_contents]
162
+ return annotations_stems == audiomoth_stems
163
+
164
+ def __download_data(self):
165
+ # make sure the directory is clear
166
+ a3em.utils.clean_directory(self.path)
167
+
168
+ # pull files metadata
169
+ print('pulling metadata')
170
+ r = requests.get(f'{Arden.api}/api/v2/datasets/{Arden.doi}/versions')
171
+ if r.status_code != 200:
172
+ raise RuntimeError(r.text)
173
+ content = r.json()
174
+ latest_version = content['_embedded']['stash:versions'][0]
175
+ files_path = latest_version['_links']['stash:files']['href']
176
+
177
+ # get individual download links
178
+ print('locating files')
179
+ r = requests.get(f'{Arden.api}/{files_path}')
180
+ if r.status_code != 200:
181
+ raise RuntimeError(r.text)
182
+ content = r.json()
183
+ files = content['_embedded']['stash:files']
184
+
185
+ # download the files
186
+ print('download in progress')
187
+ for file in files:
188
+ download_src = file['_links']['stash:download']['href']
189
+ download_dst = self.path / file['path']
190
+ r = requests.get(f'{Arden.api}/{download_src}',
191
+ headers={'authorization': f'Bearer {self.token}'})
192
+ if r.status_code != 200:
193
+ raise RuntimeError(r.text)
194
+ with open(download_dst, 'wb') as fd:
195
+ for chunk in r.iter_content(chunk_size=128):
196
+ fd.write(chunk)
197
+
198
+ # unpack the files
199
+ print('unpacking')
200
+ zip_files = sorted(self.path.glob('*.zip'))
201
+ for zip_file in zip_files:
202
+ with zipfile.ZipFile(zip_file, 'r') as zip_ref:
203
+ zip_ref.extractall(self.path)
204
+ zip_file.unlink()
205
+
206
+ def __load_metadata(self, random_state, rumble_only):
207
+ metadata = pd.DataFrame()
208
+ for stem, prefetch in self.prefetch.items():
209
+ annotation_path = prefetch['annotation_path']
210
+ annotations = pd.read_csv(annotation_path, sep='\t')
211
+ if annotations.empty:
212
+ continue
213
+
214
+ # extract elephant rumbles
215
+ file_metadata = Arden.__filter_annotations(annotations, stem)
216
+
217
+ # extract background noise if applicable
218
+ if not rumble_only:
219
+ noise_metadata = Arden.__extract_noise(annotations, stem)
220
+ file_metadata = pd.concat(
221
+ [file_metadata, noise_metadata],
222
+ ignore_index=True
223
+ )
224
+
225
+ # filter rumbles and add to dataframe
226
+ file_metadata = Arden.__filter_clips(file_metadata)
227
+ metadata = pd.concat([metadata, file_metadata], ignore_index=True)
228
+
229
+ self.metadata = (
230
+ metadata
231
+ .sample(frac=1, random_state=random_state)
232
+ .reset_index(drop=True)
233
+ )
234
+
235
+ def __load_audio_features(self, random_state=None, sample_rate=2000):
236
+ random.seed(random_state)
237
+ clips = [None] * len(self)
238
+ features = [None] * len(self)
239
+
240
+ print('extracting features')
241
+ for stem, prefetch in tqdm(self.prefetch.items()):
242
+ # load in audio file
243
+ audio_file = prefetch['audio_path']
244
+ audio, _ = librosa.load(audio_file, sr=sample_rate)
245
+
246
+ df = self.metadata[self.metadata.file_stem == stem]
247
+ for index, row in df.iterrows():
248
+ start_time = row['Begin Time (s)']
249
+ end_time = row['End Time (s)']
250
+
251
+ clip, feature_set = Arden.__extract_clip_features(
252
+ audio,
253
+ sample_rate,
254
+ start_time,
255
+ end_time
256
+ )
257
+
258
+ clips[index] = clip
259
+ features[index] = feature_set
260
+
261
+ self._features = pd.DataFrame(features)
262
+ self._clips = clips
263
+
264
+ @staticmethod
265
+ def __filter_annotations(annotations, stem):
266
+ d = ['Selection', 'View', 'Channel', 'Low Freq (Hz)', 'High Freq (Hz)']
267
+ df = annotations.drop(columns=d)
268
+ df.insert(0, 'file_stem', [stem] * len(annotations))
269
+ df['duration'] = df['End Time (s)'] - df['Begin Time (s)']
270
+ return df
271
+
272
+ @staticmethod
273
+ def __extract_noise(rumble_annotations, stem):
274
+ # get ranges for all possible rumbles
275
+ rumble_event_ranges = list(zip(
276
+ rumble_annotations['Begin Time (s)'],
277
+ rumble_annotations['End Time (s)']
278
+ ))
279
+
280
+ # get average duration of rumbles
281
+ rumble_deltas = [end - start for start, end in rumble_event_ranges]
282
+ rumble_delta_stats = np.mean(rumble_deltas), np.std(rumble_deltas)
283
+
284
+ # generate noise clips from regions without rumbles
285
+ noise_regions = Arden.__find_noise_regions(rumble_event_ranges)
286
+ clip_ranges = [
287
+ Arden.__random_clip_range(region, rumble_delta_stats)
288
+ for region in noise_regions
289
+ ]
290
+
291
+ # create dataframe
292
+ return pd.DataFrame([
293
+ {
294
+ 'file_stem': stem,
295
+ 'call_type': 'BKG',
296
+ 'Begin Time (s)': clip_start,
297
+ 'End Time (s)': clip_end,
298
+ 'quality': 0,
299
+ 'overlap': 'N',
300
+ 'earflap': 0,
301
+ 'duration': clip_end - clip_start
302
+ }
303
+ for clip_start, clip_end in clip_ranges
304
+ ])
305
+
306
+ @staticmethod
307
+ def __filter_clips(df):
308
+ df['earflap'] = pd.to_numeric(df['earflap'], errors='coerce')
309
+ return df[
310
+ (df['call_type'].isin(['RUM', 'BKG']))
311
+ & (df['earflap'].isin([0, 1]))
312
+ & (df['overlap'] == 'N')
313
+ & (df['quality'].isin([0, 2, 3, 4]))
314
+ & (df['duration'] > 2)
315
+ ]
316
+
317
+ @staticmethod
318
+ def __find_noise_regions(rumble_ranges):
319
+ start_times = [x[0] for x in rumble_ranges]
320
+ end_times = [x[1] for x in rumble_ranges]
321
+ start_times.pop()
322
+ start_times.insert(0, 0.0)
323
+ return list(zip(start_times, end_times))
324
+
325
+ @staticmethod
326
+ def __random_clip_range(boundary, delta_stats):
327
+ start_bound, end_bound = boundary
328
+ delta_mean, delta_std = delta_stats
329
+ center = (end_bound - start_bound) * random.random() + start_bound
330
+ clip_length = (
331
+ delta_mean
332
+ + delta_std
333
+ * random.random()
334
+ * random.randrange(-1, 2, 2)
335
+ )
336
+ clip_start = max(start_bound, center - clip_length / 2)
337
+ clip_end = min(center + clip_length / 2, end_bound)
338
+ return clip_start, clip_end
339
+
340
+ @staticmethod
341
+ def __extract_clip_features(audio, sample_rate, start_time, end_time):
342
+ buffer = 0.2
343
+ start_sample = max(0, int((start_time - buffer) * sample_rate))
344
+ end_sample = int((end_time + buffer) * sample_rate)
345
+ clip = audio[start_sample:end_sample]
346
+ if len(clip) < sample_rate * 2:
347
+ return [], {}
348
+ preprocessed_clip = a3em.utils.preprocess(clip, sample_rate)
349
+ features = a3em.utils.extract_features(preprocessed_clip, sample_rate)
350
+ return clip, features
@@ -0,0 +1,10 @@
1
+ pandas
2
+ numpy
3
+ librosa
4
+ soundfile
5
+ scipy
6
+ matplotlib
7
+ scikit-learn
8
+ python-dotenv
9
+ statsmodels
10
+ tqdm
@@ -0,0 +1,108 @@
1
+ import librosa
2
+ import numpy as np
3
+ import os
4
+ from pathlib import Path
5
+ from scipy.signal import butter, sosfilt
6
+
7
+
8
+ def preprocess(audio: np.ndarray, sample_rate: int, normalization: float = 0.7):
9
+ # parameter validation
10
+ if normalization < 0.0 or normalization > 1.0:
11
+ raise ValueError('the normalization factor must be between 0.0 and 1.0')
12
+
13
+ # high pass filter
14
+ high_pass = butter(2, 15, btype='highpass', fs=sample_rate, output='sos')
15
+ audio = sosfilt(high_pass, audio)
16
+
17
+ # low pass filter
18
+ low_pass = butter(2, 200, btype='lowpass', fs=sample_rate, output='sos')
19
+ audio = sosfilt(low_pass, audio)
20
+
21
+ # normalize 70%
22
+ audio = audio / np.max(np.abs(audio)) * normalization
23
+
24
+ return audio
25
+
26
+
27
+ def extract_features(audio: np.ndarray, sample_rate: int) -> float:
28
+ # spectrograms
29
+ S = np.abs(librosa.stft(audio))**2
30
+ power_per_freq = S.mean(axis=1)
31
+ S_mel = librosa.feature.melspectrogram(y=audio, sr=sample_rate, n_mels=26)
32
+
33
+ #peak freq
34
+ freqs = librosa.fft_frequencies(sr=sample_rate)
35
+ freq_mask = (freqs > 15) & (freqs < 60)
36
+ peak_freq = freqs[freq_mask][np.argmax(power_per_freq[freq_mask])]
37
+
38
+ #centroid
39
+ centroid = librosa.feature.spectral_centroid(y=audio, sr=sample_rate)
40
+ mean_centroid = centroid.mean()
41
+
42
+ # bandwidth
43
+ bandwidth = librosa.feature.spectral_bandwidth(y=audio, sr=sample_rate)
44
+ mean_bandwidth = bandwidth.mean()
45
+
46
+ # 5% and 95% freqs
47
+ cumulative = np.cumsum(power_per_freq)
48
+ cumulative = cumulative /cumulative[-1]
49
+ freq_5 = freqs[np.searchsorted(cumulative, 0.05)]
50
+ freq_95 = freqs[np.searchsorted(cumulative, 0.95)]
51
+
52
+ # MFCCS
53
+ mfccs = librosa.feature.mfcc(y=audio, sr=sample_rate, n_mfcc=13)
54
+ mfcc_dict = {f'mfcc_{i+1}': mfccs[i].mean() for i in range(13)}
55
+
56
+ mel_means = S_mel.mean(axis=1)
57
+ mel_dict = {f'mel_mean_{i+1}': v for i, v in enumerate(mel_means)}
58
+
59
+ # TODO - spectral flatness
60
+
61
+ # Harmonic-to-noise ratio (HPSS approximation)
62
+ harmonic, percussive = librosa.effects.hpss(audio)
63
+
64
+ harmonic_energy = np.sum(harmonic ** 2)
65
+ noise_energy = np.sum(percussive ** 2)
66
+
67
+ hnr = 10 * np.log10(
68
+ (harmonic_energy + 1e-10) /
69
+ (noise_energy + 1e-10)
70
+ )
71
+
72
+ # Low-frequency harmonic-to-noise ratio
73
+ low_pass = butter(2, 60, btype='lowpass', fs=sample_rate, output='sos')
74
+ low_audio = sosfilt(low_pass, audio)
75
+
76
+ harmonic, percussive = librosa.effects.hpss(low_audio)
77
+
78
+ harmonic_energy = np.sum(harmonic ** 2)
79
+ noise_energy = np.sum(percussive ** 2)
80
+
81
+ hnr_low = 10 * np.log10(
82
+ (harmonic_energy + 1e-10) /
83
+ (noise_energy + 1e-10)
84
+ )
85
+
86
+ return {
87
+ 'peak_freq': peak_freq,
88
+ 'centroid': mean_centroid,
89
+ 'bandwidth': mean_bandwidth,
90
+ 'freq_5': freq_5,
91
+ 'freq_95': freq_95,
92
+ 'hnr': hnr,
93
+ 'hnr_low': hnr_low,
94
+ **mfcc_dict,
95
+ **mel_dict
96
+ }
97
+
98
+
99
+ def clean_directory(path: Path, base_path: bool = True):
100
+ contents = list(path.iterdir())
101
+ while len(contents) != 0:
102
+ if contents[0].is_dir():
103
+ clean_directory(contents[0], base_path=False)
104
+ else:
105
+ contents[0].unlink()
106
+ contents.pop(0)
107
+ if not base_path:
108
+ path.rmdir()