Skip to content

Commit f817df1

Browse files
committed
Merge remote-tracking branch 'origin/main' into geomodel-3.0
2 parents 6c64243 + 76f4ab2 commit f817df1

18 files changed

Lines changed: 544 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.16] - 2026-05-09
11+
12+
### Added
13+
14+
- Add support for overriding BirdNET’s application-data directory via an environment variable `BIRDNET_APP_DATA`, enabling users to place downloaded models/benchmarks in a custom location (useful for deployments with restricted home directories or shared storage).
15+
16+
### Bugfixes
17+
18+
- Fixed acoustic inference session being aborted on macOS when stats were enabled: hardened parent/child memory tracking against `psutil.AccessDenied`, and replaced the two tracked semaphores with a wrapper that mirrors the count into shared memory so `get_value()` works on macOS (#39)
19+
- Fixed float16 quantization of segment timestamps in prediction results, which caused up to ±0.05 s drift in CSV/DataFrame/Parquet output (#38, #42). Also closed an analogous hole in encoding results where a hop duration that is exactly representable in float16 (e.g. hop=1.5) could still produce drifting accumulated timestamps. Timestamps are now always materialized at >= float32 precision at the source.
20+
1021
## [0.2.15] - 2026-05-02
1122

1223
### Bugfixes
@@ -264,7 +275,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
264275

265276
- Initial release
266277

267-
[Unreleased]: https://github.com/birdnet-team/birdnet/compare/v0.2.15...HEAD
278+
[Unreleased]: https://github.com/birdnet-team/birdnet/compare/v0.2.16...HEAD
279+
[0.2.16]: https://github.com/birdnet-team/birdnet/compare/v0.2.15...v0.2.16
268280
[0.2.15]: https://github.com/birdnet-team/birdnet/compare/v0.2.14...v0.2.15
269281
[0.2.14]: https://github.com/birdnet-team/birdnet/compare/v0.2.13...v0.2.14
270282
[0.2.13]: https://github.com/birdnet-team/birdnet/compare/v0.2.12...v0.2.13

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
project = "birdnet"
1010
copyright = "2026, Stefan Taubert"
1111
author = "Stefan Taubert"
12-
release = "0.2.15"
12+
release = "0.2.16"
1313

1414
# -- General configuration ---------------------------------------------------
1515
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

docs/general.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,25 @@ A *Producer* loads only as much audio as the buffer can hold, keeping RAM usage
2929
* **Buffer Size** – By default, the buffer is set to twice the *Worker* count, ensuring that every *Worker* always has a pre-loaded batch to process and thus avoids idle time.
3030
* **Model Backends** – Each worker loads its own instance of the inference model. On the CPU, both **TFLite** and **Protocol Buffers** (Protobuf) models can be used; Protobuf models can optionally run on the GPU.
3131
* **Best Practice for CPU Inference** – For CPU-only execution on Linux, the number of *Worker* processes should not exceed the number of physical cores, as oversubscription typically leads to reduced performance. When running TFLite, keep the batch size to one (1); larger batches offer no throughput benefit.
32+
33+
Known limitations
34+
----
35+
36+
**End-time precision on the last segment of short files (≤ ~34 minutes).**
37+
For memory efficiency, per-file durations are stored in the smallest float
38+
dtype that covers their magnitude: ``float16`` for files up to 2\ :sup:`11` ≈
39+
2048 s, ``float32`` for files up to 2\ :sup:`24` s (~194 days), ``float64``
40+
beyond. The stored duration is used as the upper clamp when computing the
41+
``end_time`` of the *last* segment of each file. Inside the float16 range
42+
this rounding is visible: the largest representable float16 below ``X`` may
43+
differ from ``X`` by up to one ULP — about 0.06 s near 128 s, 0.25 s near
44+
1024 s, and 0.5 s near 2048 s. The error appears only on the very last
45+
segment per file and only when the actual file duration is not exactly
46+
representable in float16 (integer-second durations up to 2048 s are
47+
exact). For files of one hour or longer the storage dtype is float32, where
48+
the equivalent ULP is below 4 ms even at 12 h, so the effect is not
49+
observable in practice.
50+
51+
All other timestamps (``start_time`` and ``end_time`` of every segment that
52+
does not hit the clamp) are computed at ≥ float32 precision regardless of
53+
file length.

docs/setup.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@ All BirdNET data (models, benchmarks) is stored in the application-data director
281281
- **macOS:** ``~/Library/Application Support/birdnet``
282282
- **Windows:** ``%APPDATA%/birdnet``
283283

284+
The default location can be overridden by setting the ``BIRDNET_APP_DATA`` environment variable to any absolute path before the ``birdnet`` package is imported. ::
285+
286+
# Windows pre-execution script
287+
set BIRDNET_APP_DATA=C:\Program Files\BirdNET-Analyzer\birdnet-data
288+
289+
# Linux / macOS pre-execution script
290+
export BIRDNET_APP_DATA=/opt/birdnet-analyzer/birdnet-data
291+
284292
Why is Python 3.10 not supported?
285293
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
286294

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "birdnet"
3-
version = "0.2.15"
3+
version = "0.2.16"
44
description = "A Python library for identifying bird species by their sounds."
55
readme = "README.md"
66
requires-python = ">=3.11, <3.14"

src/birdnet/acoustic/inference/core/encoding/encoding_result.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
format_input_for_csv,
2222
get_uint_dtype,
2323
hms_centis_fast,
24-
upgrade_float_dtype_for_value,
2524
)
2625

2726
if TYPE_CHECKING:
@@ -135,11 +134,11 @@ def to_structured_array(self) -> np.ndarray:
135134
embeddings_selected = self.embeddings[valid_file_idx, valid_seg_idx]
136135

137136
hop_duration_s = self.hop_duration_s
138-
# Upgrade the storage dtype for the output if it cannot represent hop
139-
# exactly, otherwise rounding accumulates across segments.
140-
time_dtype = upgrade_float_dtype_for_value(
141-
self._input_durations.dtype, hop_duration_s
142-
)
137+
# Force at least float32 for timing columns. The bulk _input_durations
138+
# array is stored in a magnitude-based dtype (float16 for files <= 2**11 s),
139+
# which is too coarse for accumulated i*hop products and would also produce
140+
# Arrow halffloat that some implementations (e.g. R) cannot read.
141+
time_dtype = np.result_type(self._input_durations.dtype, np.float32)
143142

144143
dtype = [
145144
(VAR_INPUT, self._input_dtype),

src/birdnet/acoustic/inference/core/perf_tracker.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from collections.abc import Callable
1111
from dataclasses import dataclass
1212
from multiprocessing import Queue, shared_memory
13-
from multiprocessing.synchronize import Event, Semaphore
13+
from multiprocessing.synchronize import Event
1414
from queue import Empty
1515

1616
import numpy as np
1717
import psutil
1818

1919
import birdnet.acoustic.inference.core.logs as bn_logging
2020
from birdnet.acoustic.inference.core.shm import RingField
21+
from birdnet.acoustic.inference.core.sync import CountedSemaphore
2122
from birdnet.globals import READABLE_FLAG, READING_FLAG, WRITABLE_FLAG
2223

2324

@@ -124,8 +125,8 @@ def __init__(
124125
logging_queue: Queue,
125126
logging_level: int,
126127
perf_res: Queue,
127-
sem_active_workers: Semaphore,
128-
sem_filled_slots: Semaphore,
128+
sem_active_workers: CountedSemaphore,
129+
sem_filled_slots: CountedSemaphore,
129130
segment_size_s: float,
130131
parent_process_id: int,
131132
rf_flags: RingField,
@@ -275,20 +276,42 @@ def reset(self) -> None:
275276
self._prd_speed_xrt_tracker.reset()
276277
self._prd_speed_seg_per_s_tracker.reset()
277278

279+
@staticmethod
280+
def _safe_proc_memory(proc: psutil.Process) -> float | None:
281+
try:
282+
return float(proc.memory_full_info().uss)
283+
except (psutil.AccessDenied, PermissionError):
284+
pass
285+
except psutil.NoSuchProcess:
286+
return None
287+
try:
288+
return float(proc.memory_info().rss)
289+
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
290+
return None
291+
278292
def _track_memory_usage(self) -> None:
279293
if self._parent_process is None:
280-
self._parent_process = psutil.Process(self._parent_process_id)
281-
memory_usage: float = self._parent_process.memory_full_info().uss
282-
for child in self._parent_process.children(recursive=True):
283294
try:
284-
memory_usage += child.memory_full_info().uss
285-
except psutil.NoSuchProcess:
286-
continue
287-
except psutil.AccessDenied:
288-
continue
289-
290-
mem_usage_MiB = memory_usage / 1024**2
291-
self._memory_usage_MiB_tracker.add_value(mem_usage_MiB)
295+
self._parent_process = psutil.Process(self._parent_process_id)
296+
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
297+
return
298+
299+
parent_mem = self._safe_proc_memory(self._parent_process)
300+
if parent_mem is None:
301+
return
302+
303+
total = parent_mem
304+
try:
305+
children = self._parent_process.children(recursive=True)
306+
except (psutil.AccessDenied, PermissionError, psutil.NoSuchProcess):
307+
children = []
308+
309+
for child in children:
310+
child_mem = self._safe_proc_memory(child)
311+
if child_mem is not None:
312+
total += child_mem
313+
314+
self._memory_usage_MiB_tracker.add_value(total / 1024**2)
292315

293316
@property
294317
def wall_time(self) -> float:

src/birdnet/acoustic/inference/core/prediction/prediction_result.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import os
44
from pathlib import Path
5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING
66

77
import numpy as np
88
from ordered_set import OrderedSet
@@ -153,10 +153,15 @@ def to_structured_array(self) -> np.ndarray:
153153
del valid_mask
154154

155155
n_predictions = len(valid_indices[0])
156+
# Force at least float32 for timing columns. The bulk _input_durations
157+
# array is stored in a magnitude-based dtype (float16 for files <= 2**11 s),
158+
# which is too coarse for accumulated i*hop products and would also produce
159+
# Arrow halffloat that some implementations (e.g. R) cannot read.
160+
time_dtype = np.result_type(self._input_durations.dtype, np.float32)
156161
dtype = [
157162
(VAR_INPUT, self._input_dtype),
158-
(VAR_START_TIME, self._input_durations.dtype),
159-
(VAR_END_TIME, self._input_durations.dtype),
163+
(VAR_START_TIME, time_dtype),
164+
(VAR_END_TIME, time_dtype),
160165
(VAR_SPECIES_NAME, object),
161166
(VAR_CONFIDENCE, self._species_probs.dtype),
162167
]
@@ -191,15 +196,15 @@ def to_structured_array(self) -> np.ndarray:
191196
del sort_indices
192197

193198
hop_duration_s = self.hop_duration_s
194-
start_times = chunk_idx_flat.astype(self._input_durations.dtype) * hop_duration_s
199+
start_times = chunk_idx_flat.astype(time_dtype) * hop_duration_s
195200
del hop_duration_s
196201
del chunk_idx_flat
197202

198203
structured_array[VAR_START_TIME] = start_times
199204
structured_array[VAR_END_TIME] = np.minimum(
200205
start_times
201206
+ apply_speed_to_duration(self._segment_duration_s[0], self._speed[0]),
202-
self._input_durations[file_idx_flat],
207+
self._input_durations[file_idx_flat].astype(time_dtype),
203208
)
204209
del start_times
205210
structured_array[VAR_INPUT] = self._inputs[file_idx_flat]

src/birdnet/acoustic/inference/core/producer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import birdnet.acoustic.inference.core.logs as bn_logging
2121
from birdnet.acoustic.inference.core.shm import RingField
22+
from birdnet.acoustic.inference.core.sync import CountedSemaphore
2223
from birdnet.globals import (
2324
READABLE_FLAG,
2425
READING_FLAG,
@@ -54,7 +55,7 @@ def __init__(
5455
rf_batch_sizes: RingField,
5556
rf_flags: RingField,
5657
sem_free_slots: Semaphore,
57-
sem_filled_slots: Semaphore,
58+
sem_filled_slots: CountedSemaphore,
5859
max_segment_idx_ptr: ctypes.c_uint8
5960
| ctypes.c_uint16
6061
| ctypes.c_uint32
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import multiprocessing as mp
4+
from multiprocessing.sharedctypes import Synchronized
5+
from types import TracebackType
6+
7+
8+
class CountedSemaphore:
9+
"""
10+
Drop-in replacement for ``mp.Semaphore`` whose ``get_value()`` works on
11+
macOS by mirroring acquire/release into a shared counter.
12+
"""
13+
14+
def __init__(self, initial: int = 0) -> None:
15+
self._sem = mp.Semaphore(initial)
16+
self._counter: Synchronized = mp.Value("i", initial)
17+
18+
def acquire(self, block: bool = True, timeout: float | None = None) -> bool:
19+
acquired = self._sem.acquire(block, timeout)
20+
if acquired:
21+
with self._counter.get_lock():
22+
self._counter.value -= 1
23+
return acquired
24+
25+
def release(self) -> None:
26+
with self._counter.get_lock():
27+
self._counter.value += 1
28+
self._sem.release()
29+
30+
def get_value(self) -> int:
31+
return self._counter.value
32+
33+
def __enter__(self) -> bool:
34+
return self.acquire()
35+
36+
def __exit__(
37+
self,
38+
exc_type: type[BaseException] | None,
39+
exc: BaseException | None,
40+
tb: TracebackType | None,
41+
) -> None:
42+
self.release()

0 commit comments

Comments
 (0)