Guidance for coding agents working in this repository. It is the single source of
truth for agent instructions — CLAUDE.md just points here. It holds only what the
codebase does not already state: anything you can read off pyproject.toml, a
workflow file or a directory listing is deliberately not repeated here.
Personal or machine-specific preferences (which virtualenv to use, whether an agent
may commit) belong in an untracked CLAUDE.local.md / AGENTS.local.md, which
.gitignore already covers.
pip install -e .[dev] gets ruff, pytest and the docs toolchain; CI additionally
installs embeddings,train,gui-tests.
The test audio is a git submodule. Without
git submodule update --init --recursive most tests cannot run. CONTRIBUTING.md
covers updating the ref.
Models download on first use. Inference comes from the birdnet dependency,
which fetches roughly 120 MB (acoustic), 35 MB (geo) and 380 MB (perch-v2) into the
directory named by BIRDNET_APP_DATA — read at import time — skipping files
that already exist. CI points it at a cached workspace path; Dockerfile bakes the
models into the image. Tests have a 120 s timeout that a cold download can exceed,
which is why CI pre-downloads outside pytest. Do the same locally if a first run
times out:
python -c "import birdnet; birdnet.load('acoustic', '2.4', 'tf', lang='en_us')"
Run ruff check and python -m pytest. The ruff version is pinned in two
places — the [dev] extra and lint.yml — and has to be bumped in both.
birdnet_analyzer wraps the birdnet library, which does the actual inference.
Feature subpackages (analyze/, embeddings/, search/, species/, segments/,
train/) follow core.py (public API, re-exported in __init__.py) + cli.py +
__main__.py. evaluation/ does not: its main() lives in __init__.py and
the logic sits under assessment/ and preprocessing/.
params.py reads the *-params.csv files a run writes back into keyword arguments,
shared by the CLI (where they become argument defaults) and the GUI. logs.py
leaves logging configuration to the CLI/GUI entry points — the package itself only
attaches a NullHandler. The GUI is a Gradio app in a pywebview window, one module
per tab exposing build_*_tab, assembled in gui/__init__.py:main.
settings.APPDIR is a per-OS user data directory (BirdNET-Analyzer-GUI) holding
GUI settings, tab state (state.json) and the logs — not the checkout. GUI
behaviour can therefore differ between machines because of leftover local state, and
deleting that directory is how you test a first-run experience.
- The GUI test environment has no pywebview, no plotly and no pywin32 —
gui-testsinstalls gradio only, the others aregui-only, and pywin32 is imported in one place, inside_get_network_shortcuts. So no GUI module may importwebvieworplotlyat module import time:gui/utils.pyimportswebviewinside the dialog functions andopen_windowonly, and its_WINDOWannotation is a string underTYPE_CHECKING. The tests importgui.utilsunstubbed on purpose. A test that builds species-list controls must stubgu.plot_map_scatter_mapbox(it draws a plotly map). A local venv with theguiextra hides these problems, so run GUI tests once withwebview,plotly,pythoncomandwin32commade unimportable before pushing. - Localization is all-or-nothing.
lang/*.jsonholds one file per language. A new UI string has to be added to every file with a real translation — not the English text copied over. Write them with a json load/dump round-trip (ensure_ascii=False, indent=4, sort_keys=True);tests/gui/test_language.pyenforces that formatting, key parity across languages, and that each translation keeps thestr.formatfields of itsen.jsonsource. - A GUI setting has three ways to get its value, not one. Every persisted
control (
TabState.persist) is set (1) at build time fromstate.json, (2) by the user, and (3) programmatically by presets and loaded*-params.csvfiles (TabState.updates_for, which setsvalueonly). So when one control depends on another (the sensitivity slider disabled for BirdNET 3.0/Perch, the locale dropdown offering only the model's languages, …), cover all three: pass the dependency into the builder for the initial state, handle it in a.changehandler (gradio fires it for programmatic updates too —.inputdoes not), and have the handler reset the dependent value when the loaded one is no longer valid, since the batch update landed before the handler ran. Doing only (2) is the classic gap. - Code comments: only when the code cannot say it. Default to none. A comment earns its place for a constraint, a non-obvious why, or a measured value that justifies a bound. No history ("used to", "previously"), no narration of the next line — that belongs in commit messages and the changelog. Same bar for tests: the test name and its assertions are the explanation.
- Don't widen the ruff config to make a fix pass. The
select/ignorelists inpyproject.tomlare deliberate. - Packaging is an allow-list.
[tool.setuptools]'spackages/package-datadecide what ships, so a new subpackage or data file that is not listed there is silently missing from the install..dockerignoreis separate and needs its own update for new top-level cruft.