BirdNet-Behavior
Real-time acoustic bird classification with behavioral analytics — written in Rust, runs on a Raspberry Pi.
BirdNet-Behavior listens to your microphone or RTSP camera, identifies birds in real time with a BirdNET neural network (BirdNET V2.4 by default), and serves a fast, beautiful web dashboard you open in any browser. It ships as one self-contained binary — the ONNX Runtime and DuckDB analytics engine are compiled in, so there’s no Python, no pip, no virtualenv. Drop it on a modern 64-bit Pi and run it.
Where to start
| If you want to… | Go to |
|---|---|
| Get it running in one command | Installation |
| Run it in a container | Running with Docker |
| Understand the settings | Configuration |
| Learn what each screen does | Today |
| Move from BirdNET-Pi | Migrating from BirdNET-Pi |
| Fix a problem | Troubleshooting |
Why a rewrite?
BirdNet-Behavior is a rewrite of BirdNET-Pi in Rust, derived from it and licensed accordingly. It keeps everything BirdNET-Pi does and adds behavioral analytics, a redesigned UI, MQTT/Home Assistant integration, and a much lighter runtime.
| Aspect | BirdNET-Pi (Python) | BirdNet-Behavior (Rust) |
|---|---|---|
| Runtime | CPython interpreter + virtualenv | One native binary — no interpreter |
| Install | pip into a venv + system packages | one file, or one curl installer |
| Upgrade | re-resolve pip dependencies | replace one file |
| Inference | TensorFlow Lite (Python) | ONNX Runtime, linked in-process |
| Analytics | — | DuckDB behavioral engine, built in |
| Each release ships | — | signed SLSA provenance + CycloneDX SBOM |
It is a rewrite of BirdNET-Pi in Rust, derived from it and licensed accordingly (the repository’s
LICENSEsays “derived from”). See Credits & License for full attribution.
A modern, two-audience interface
The UI is organized into six homes — the tabs across the top, and the phone bottom bar:
| Home | “…” |
|---|---|
| Today | what’s happening right now? |
| Species | who have I heard? |
| Patterns | when & where? |
| Recordings | let me hear them |
| Reports | the recap |
| Settings | manage my station |
Every screen is designed to serve two people at once: the hobbyist who wants a delightful at-a-glance view, and the researcher who needs methodological rigor. Each page leads with a plain-English headline, then layers the dense numbers and charts beneath. The whole UI supports light and dark themes (with automatic OS-preference detection) and reflows cleanly from a wall-mounted kiosk down to a phone.
Read on for installation, or jump into the field guide for a tour of every screen.
Installation
There are two supported ways to install BirdNet-Behavior, and each is one command. The bare-metal installer puts a native binary and a hardened systemd service on a Raspberry Pi or a modern x86_64 Linux machine. Docker carries its own runtime, so it also works where the native binary cannot run (Raspberry Pi OS Bookworm), and it suits a machine that already runs containers. Neither is the lesser path; choose by what the host is.
Supported hardware & OS
| Platform | Status | Notes |
|---|---|---|
| Raspberry Pi 5 | ✅ Recommended | 64-bit Raspberry Pi OS Trixie |
| Raspberry Pi 4B / 400 | ✅ Fully supported | 64-bit Raspberry Pi OS Trixie |
| Any x86_64 Linux | ✅ Fully supported | glibc ≥ 2.39 (Debian 13 / Ubuntu 24.04 or newer) |
| Raspberry Pi OS Bookworm (glibc 2.36) | ⚠️ Docker only | The native binary needs glibc ≥ 2.39. Run the Docker image (it bundles its own runtime, so the host glibc does not matter) or upgrade to Trixie. |
| Pi 3 / Pi Zero 2 W on a 32-bit OS (armv7) | ❌ | No prebuilt ONNX Runtime exists for armv7. These boards are 64-bit-capable — reflash with the 64-bit Pi OS. |
Runtime requirement: glibc ≥ 2.39. The prebuilt binaries are built on Ubuntu 24.04 to match the baseline that pyke’s ONNX Runtime requires, so they do not run on Raspberry Pi OS Bookworm / Debian 12 (glibc 2.36).
install.shchecks this and refuses with a clear message rather than installing a binary that won’t start. Bookworm users: use Docker. Each prebuilt binary also ships the DuckDB behavioral-analytics engine built in and on by default — the installer’s systemd unit enables it automatically (for a manual run, pass--analytics-db).
- Storage: about 1.5 GB free to start, most of it for recordings and the database. The default classifier is about 85 MB (BirdNET V2.4: a 54 MB model and a 30 MB range filter); the BirdNET+ V3.0 preview you can choose instead is 541 MB, plus a 14 MB occurrence model. The installer warns below 600 MB free for V2.4 and 900 MB for V3.0, because the model installer will not run without its own size plus a 512 MiB margin free.
- Audio input — one of:
- a USB microphone or USB sound card (
arecordfromalsa-utils), or - an IP camera or any RTSP stream (
ffmpeg).
- a USB microphone or USB sound card (
Which classifier a new install runs
A new bare-metal install, a new Docker volume and the macOS formula run BirdNET V2.4 with its v2 range (occurrence) filter — the network BirdNET-Pi and birdnet-go run, so the station agrees with them about what it heard (Same audio, same detections says what was measured). BirdNET+ V3.0 preview, the network earlier releases installed for everyone, is an explicit choice:
# bare metal: a flag, or the environment variable; Docker: BIRDNET_CLASSIFIER in .env
curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh | sudo bash -s -- --classifier birdnet-v3
The values are birdnet-v24 and birdnet-v3 (--classifier, or BIRDNET_CLASSIFIER for the installer, the container and quickstart.sh, which writes it to .env only if you set it). An existing station never changes model on an install, update, repair or reinstall: the installer reads the classifier that /etc/birdnet/birdnet.conf names and keeps it, and says so (a --classifier that disagrees is reported as not applied). A Docker volume that already holds the V3.0 model file keeps V3.0 and logs one line saying how to change that. Changing a running station’s classifier splits its history between two networks that name and score birds differently, so it is something you do on purpose, by editing MODEL_PATH, LABELS_PATH and METADATA_MODEL_PATH; the classifiers page has the details.
Where the files come from. The installer and the container ask the binary to install V2.4 (birdnet-behavior --install-model birdnet-v24 and birdnet-v24-range-v2), and the binary checks each file against a SHA-256 compiled into it. The model comes from birdnet-go’s published ONNX build on Hugging Face (huggingface.co/tphakala/BirdNET-v2.4) and the range filter from raw.githubusercontent.com (tphakala/birdnet-go), each at a pinned revision. The label file is not downloaded: the binary carries it, with BirdNET-Pi’s English names. So a default install needs huggingface.co and raw.githubusercontent.com as well as github.com (for the binary), and nothing in it comes from this project’s own model release. (The V2.4 files are also mirrored in this repository’s models-v2.4 release, published on 2026-10-20 with publish-model.yml; nothing fetches from it yet, because the binary’s catalogue names one origin per file.) V3.0 still comes from this project’s models-v3.0-preview3 release, with Zenodo as the fallback. Either way you never locate a model yourself; the files are installed into the data directory’s models/ folder on bare metal and /data/model in Docker.
Option 1 — Docker quick start
One command. It asks two or three plain-English questions, auto-detects your USB mic, writes a minimal .env, and starts the container. No git clone, no editor, no hand-picking compose overlays.
bash <(curl -fsSL https://raw.githubusercontent.com/tomtom215/BirdNet-Behavior/main/quickstart.sh)
What the quick-start script does
- Verifies Docker Engine + Compose are installed and usable (with clear remediation if not).
- Checks disk space and that port 8502 is free.
- Creates
~/birdnet-behavior/for your.envand compose files. - Auto-detects your audio source — USB/ALSA card, PulseAudio/PipeWire, or falls back to asking for an RTSP URL.
- Asks for your station latitude/longitude (with opt-in IP auto-detect via ipwho.is over TLS: it sees the station’s public IP address).
- (DuckDB behavioral analytics is built into every image and on by default — nothing to choose.)
- Writes a short
.envwith only your chosen values and a generated admin password (the file is readable by you alone). - Starts the container with the matching compose overlay.
- Streams logs so you can watch the one-time model install (about 85 MB for the default BirdNET V2.4; 541 MB if you chose the V3.0 preview with
BIRDNET_CLASSIFIER=birdnet-v3). - Stops tailing as soon as the web server reports healthy, then prints the dashboard URL, your LAN IP and the admin login (user
admin; the password is also in~/birdnet-behavior/.env, asCADDY_PWD).
See Running with Docker for the manual path, docker run, and audio-source overlays.
Option 2 — Bare-metal installer (no Docker)
curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh | sudo bash
That address always serves the newest published release’s install.sh, the same file that release lists in SHA256SUMS and attests with build provenance — not whatever the development branch holds that minute. (Fetching it from the branch, as earlier versions of this page said, ran unreleased code as root.) --proto '=https' --tlsv1.2 refuses a downgrade on the way.
The installer runs pre-flight checks (required tools, free disk, glibc version), detects your architecture, downloads the pre-built binary, installs the classifier (BirdNET V2.4 and its range filter unless you chose otherwise, above), creates the config/recording/model directories, installs and enables a systemd service, auto-detects your ALSA microphone, and starts the service immediately. When it finishes it runs post-install validation — the binary runs, systemd-analyze verify passes, directory ownership and config are correct, the doctor preflight is clean, and the port is listening.
The dashboard binds to all interfaces (0.0.0.0:8502) so it is reachable across your LAN right away. Viewing is open; everything that changes something — the /admin panel, and deleting, relabelling, reviewing or locking a detection — is gated by a strong admin password the installer auto-generates (username admin) — it is printed once in the post-install summary and stored as CADDY_PWD in /etc/birdnet/birdnet.conf. Save it. If you answer “restrict to this device” in the interactive prompts (or set BIRDNET_LISTEN=127.0.0.1:8502), the dashboard is reachable only from the Pi itself. If your config has an RTSP_URL, the installer also installs ffmpeg (required for RTSP capture) via apt, or prints the exact command if it can’t.
# Install a specific release (defaults to the latest). The `-s --` passes the
# argument through to the installer. Replace 0.17.1 with a release that exists:
# https://github.com/tomtom215/BirdNet-Behavior/releases
curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh | sudo bash -s -- --version 0.17.1
Don’t run the installer with
sudo bash <(curl ...). Process substitution handsbasha file descriptor owned by your user;sudocloses it crossing to root, so the script disappears with/dev/fd/63: No such file or directory. The pipe forms above (curl ... | sudo bash) avoid that entirely. If you’d rather inspect the script first, download it and run it directly:curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh -o install.sh sudo bash install.sh --version 0.17.1
Update, repair, reinstall, uninstall
When install.sh detects an existing install it offers a menu — update,
repair, reinstall, or uninstall — or you can pass the command
explicitly:
sudo bash install.sh update # swap in the latest binary, restart (the default)
sudo bash install.sh repair # fix a broken install without re-downloading
sudo bash install.sh reinstall # re-download and reinstall from scratch
sudo bash install.sh uninstall # remove the software (see Uninstalling below)
Update stops the service, swaps in the new binary, and restarts it; your configuration and the SQLite/DuckDB databases are preserved, and schema migrations run automatically on the next start. Repair re-creates missing directories, fixes ownership/permissions, rewrites the systemd unit, and restarts — without re-downloading anything. It is the go-to fix for a service that won’t start (a stale unit, wrong permissions, a missing directory).
A non-interactive run (no TTY, or --noninteractive) auto-updates an
existing install rather than prompting.
Uninstalling
The quickest route is the installer’s own subcommand, which removes the software
and keeps your data by default (set BIRDNET_PURGE=1, or answer the interactive
prompt, to also delete the data and config):
sudo bash install.sh uninstall # remove the software, keep all data
sudo BIRDNET_PURGE=1 bash install.sh uninstall # also remove data + config
For full control, uninstall.sh ships next to the binary in every release
tarball (and as a standalone release asset). It is safe by default — it
removes only the software (the systemd service, the tmpfs mount unit, and the
binary) and keeps your database, recordings, settings, and the downloaded
model unless you opt in. It is idempotent (re-running is harmless) and refuses
to touch system directories.
sudo ./uninstall.sh # remove the software, keep all data
sudo ./uninstall.sh --dry-run # preview the exact plan, change nothing
sudo ./uninstall.sh --purge # remove EVERYTHING (db, recordings, model, config, zram unit)
# Or pick precisely what to delete:
sudo ./uninstall.sh --remove-db --remove-recordings
It auto-detects your real data directory from the installed config and service
files, so it removes exactly what was installed. If the config and service are
already gone, it can’t detect the data directory and refuses to delete from
a guessed path — re-run with --data-dir <path> (it tells you which path it
guessed). Both uninstallers run systemctl reset-failed so no ghost failed unit
lingers. On macOS it instead unloads the launchd LaunchAgent; pass --purge to
also remove the user data directory.
Docker deployments are torn down with Compose instead — from the directory
holding your docker-compose.yml:
docker compose down # stop + remove the container, keep named volumes
docker compose down -v # also remove volumes (deletes the database!)
Building from source
Prerequisites: Rust 1.95+ and git.
git clone https://github.com/tomtom215/BirdNet-Behavior.git
cd BirdNet-Behavior
cargo build --release # optimized build; DuckDB analytics is a default feature (~7 min first build)
cargo build --release --no-default-features # without analytics (~3–5 min)
cargo build --release --features profiling # with CPU profiles for administrators (see System)
cross build --release --target aarch64-unknown-linux-gnu # cross-compile for a Pi
Once installed, head to First Steps to open the dashboard.
Running with Docker
The quick-start script is the easiest path. If you prefer to do it by hand, you only need to decide two things:
- Station location — latitude and longitude.
- Audio source — one of: USB/ALSA mic, PulseAudio/PipeWire, or an RTSP stream URL.
Everything else has sensible defaults — including DuckDB behavioral analytics, which is built into the image and enabled by default.
1. Clone and create your .env
git clone https://github.com/tomtom215/BirdNet-Behavior.git
cd BirdNet-Behavior
cp .env.example .env
2. Edit the REQUIRED section
Pick one audio variable and leave the others blank.
# Station location
BIRDNET_LATITUDE=42.3601
BIRDNET_LONGITUDE=-71.0589
# Timezone — detections are filed under the container's local hours, which
# come from this. Unset, the container runs on UTC and says so at start.
TZ=America/New_York
# Audio source — set exactly ONE
BIRDNET_ALSA_DEVICE=plughw:CARD=PRO,DEV=0 # USB/ALSA mic — see the note below
# BIRDNET_RTSP_URL=rtsp://cam.lan:554/stream
# BIRDNET_PIPEWIRE_DEVICE=default
# Image tag — pin a release such as 0.17.1 (the releases are listed at
# https://github.com/tomtom215/BirdNet-Behavior/releases), or leave it as latest
# (analytics is built in)
BIRDNET_IMAGE_TAG=latest
# Classifier — leave unset. A new volume then runs BirdNET V2.4 (with its range
# filter); a volume that already holds the BirdNET+ V3.0 model keeps V3.0.
# BIRDNET_CLASSIFIER=birdnet-v24 # or birdnet-v3 for the V3.0 preview
Name the card, do not number it. Run
arecord -lon the host and read the word aftercard N:— that is the card’s id, andplughw:CARD=<id>,DEV=0addresses it by that id. A card index (the1inplughw:1,0) is assigned in detection order, so a reboot, a re-plug, or a second sound device can renumber it, and nothing in this station re-resolves a device string once capture has started: the supervisor retries the old name forever and the microphone stays down until someone intervenes. See Audio & Microphones for the full explanation and for how to pin a name with a udev rule.
3. Start the stack
docker compose up -d pulls the image and starts everything — analytics is built in, so there is no variant to choose.
# A) RTSP camera / multi-stream / file-watch mode — no microphone hardware
docker compose up -d
# B) USB / ALSA microphone (most Raspberry Pi setups)
docker compose -f docker-compose.yml -f docker-compose.alsa.yml up -d
# C) PulseAudio / PipeWire (desktop Linux)
docker compose -f docker-compose.yml -f docker-compose.pulse.yml up -d
Your recordings, database, and cached model live in the birdnet-data named volume, so they survive restarts and image upgrades.
4. Watch the first-run model install
On a new volume the container installs the classifier before it starts the web server, and says which one in a single Classifier: log line. By default that is BirdNET V2.4 and its range filter v2, about 85 MB: the container asks the binary (birdnet-behavior --install-model birdnet-v24, then birdnet-v24-range-v2), which downloads each file, checks it against a SHA-256 compiled into the binary, and renames it into place only when it matches. The model comes from Hugging Face and the range filter from raw.githubusercontent.com (see Installation); the label file is written by the binary itself. A file that is already on the volume is not fetched again, so a restart installs nothing, and the install happens once per named volume.
docker compose logs -f birdnet
Which classifier a start runs, first match:
| Set | Runs |
|---|---|
BIRDNET_CLASSIFIER=birdnet-v24 or birdnet-v3 | that one (any other value stops the container and names the two) |
BIRDNET_MODEL to a model of your own | your model, with the BirdNET+ V3.0 labels and occurrence filter as defaults, unless it names the V2.4 file |
| nothing, and the V3.0 model file is already on the volume | V3.0, with one log line saying the volume is kept on it and how to change that |
| nothing, on a new volume | BirdNET V2.4 |
So an upgrade never switches a running station’s model: every volume created by a release that defaulted to V3.0 holds the V3.0 file. Setting BIRDNET_CLASSIFIER=birdnet-v24 on such a volume does move it to V2.4, and its history then contains rows from two networks (see the classifiers page).
With BIRDNET_CLASSIFIER=birdnet-v3 the container instead downloads the BirdNET+ V3.0 model (~541 MB) and its occurrence model from this project’s models-v3.0-preview3 release, with Zenodo as the fallback. That download resumes if it is interrupted (curl --continue-at -); the V2.4 install does not resume, it starts the file again on the next start. The health check has a 15-minute start period to accommodate a slow first run either way.
Once you see Starting birdnet-behavior, open http://localhost:8502.
If the download fails, the entrypoint fails loud (not silent) with the exact cause and remediation. See Troubleshooting → Model not found.
Single docker run (no compose)
For the absolute minimum — no clone, no compose files — edit the four marked values and paste:
docker run -d \
--name birdnet-behavior \
--restart unless-stopped \
-p 8502:8502 \
-v birdnet-data:/data \
--device /dev/snd \
--group-add audio \
-e BIRDNET_LATITUDE=42.3601 \
-e BIRDNET_LONGITUDE=-71.0589 \
-e BIRDNET_ALSA_DEVICE=plughw:CARD=PRO,DEV=0 \
-e BIRDNET_LISTEN=0.0.0.0:8502 \
ghcr.io/tomtom215/birdnet-behavior:latest
docker logs -f birdnet-behavior # watch the model install
For RTSP, drop --device /dev/snd --group-add audio and set BIRDNET_RTSP_URL= instead of BIRDNET_ALSA_DEVICE.
Pre-built images
| Tag | Contents |
|---|---|
ghcr.io/tomtom215/birdnet-behavior:latest | Latest release — includes DuckDB behavioral analytics |
ghcr.io/tomtom215/birdnet-behavior:0.17.1 | A specific release (X.Y.Z; X.Y and X tags follow the newest patch). Replace 0.17.1 with a release that exists |
Published for linux/amd64 and linux/arm64, and signed with cosign (keyless). There is no separate -analytics image — every image has analytics built in.
Data layout & compose reference
All persistent data lives in one Docker volume at /data. Name it (-v birdnet-data:/data, as the compose file does): the image declares /data a volume, so a docker run with no -v still gets one there, but an anonymous volume with a random name that nothing reattaches to the next container, and that docker rm -v or docker run --rm deletes.
The data is lost with the container only when the data directory is not on a mount at all: DB_PATH in a mounted birdnet.conf pointing outside /data, or an image built without the VOLUME line. The station says so in three places: the Station Health banner, the badge in the page header, and Data volume in --doctor (a warning, not an error, because the station works) with the fix. The check reads the container’s mount table, not the volume’s name, so a volume of any kind (an anonymous one too) or a bind mount counts as somewhere the data outlives the container.
The layout inside it:
/data/
model/ the classifier's ONNX model + labels (and the occurrence filter), installed on first start
recordings/ Audio segments from the capture pipeline
cache/ Wikipedia species image cache
analytics.db DuckDB behavioral analytics (compose sets BIRDNET_ANALYTICS_DB)
BirdNet-Behavior/
birds.db SQLite detections database (see note below)
The image sets no
DB_PATH, so the database file is named by the binary’s default,$HOME/BirdNet-Behavior/birds.db— and the container user’s home directory is/data. SetDB_PATHin a mountedbirdnet.confto put it somewhere else.
| File | Purpose |
|---|---|
quickstart.sh | One-command bootstrap — auto-detects audio, writes .env, starts the stack |
docker-compose.yml | Base compose — works for RTSP and file-watch mode |
docker-compose.alsa.yml | Overlay for USB/ALSA microphone (passes /dev/snd) |
docker-compose.pulse.yml | Overlay for PulseAudio/PipeWire (mounts the PA socket) |
.env.example | Documented template for every supported environment variable |
Hardware & Audio Sources
You don’t need much — a Raspberry Pi (or any Linux box) and something to listen with. This page helps you choose.
Which computer?
| Device | Notes |
|---|---|
| Raspberry Pi 5 | Recommended. Comfortable headroom for behavioral analytics and the live spectrogram. |
| Raspberry Pi 4B / 400 | Fully supported and very capable for everyday use. |
| Older 64-bit-capable Pis (3B+, Zero 2 W) | Not a supported target. STABILITY lists the Pi 4, 5 and 400, and this repository records no test on a 3B+ or a Zero 2 W. The aarch64 binary needs the 64-bit Pi OS (a 32-bit OS cannot run it at all), and these boards have 1 GB and 512 MB of RAM, which is tight. If you try one anyway, turn analytics off to save memory: pass an empty value, --analytics-db "" (removing --analytics-db only falls back to <database>.duckdb, which leaves it on), as Troubleshooting shows. |
| Any x86_64 Linux | Fully supported — an old laptop or mini-PC is a great always-on host. |
BirdNet-Behavior is undemanding — there’s no Python interpreter or virtualenv resident around the model — so the limiting factor is usually storage for recordings, not compute. Budget about 1.5 GB free to start, most of it for recordings and the database (the default BirdNET V2.4 files are about 85 MB; the V3.0 preview’s are 541 MB).
Which microphone?
Any of these work — the goal is a clean signal, not an expensive one.
- USB conference/lavalier mics and USB sound cards with an electret capsule are the easy default. They show up as an ALSA card (
arecord -l) and need no drivers. - Dedicated USB measurement mics (e.g. an omnidirectional electret) give the best results outdoors.
- An old USB webcam’s mic will even work for a first test.
What matters more than the mic:
- Placement — outside, off a hard wall (which echoes), with a simple foam windscreen. Wind is the number-one source of false positives.
- Levels — aim for peaks near −6 dB on the Audio settings level meter. Too hot clips the loudest calls; too quiet buries the faint ones.
RTSP cameras (no microphone hardware)
Many bird-box and feeder cameras expose an RTSP stream that carries audio. Point BirdNet-Behavior at the URL and it treats the camera as a microphone — no sound card required. You can run several streams at once.
BIRDNET_RTSP_URL=rtsp://cam.lan:554/stream
# or multiple, comma-separated:
BIRDNET_RTSP_URLS=rtsp://cam1.lan/stream,rtsp://cam2.lan/stream
RTSP capture needs ffmpeg on the host. When your config already has an RTSP_URL, the bare-metal installer (install/repair) installs it for you via apt, or prints the exact command if it can’t; the Docker image bundles it. Add the camera under Cameras and streams on the Capture tab — which also takes HTTP/Icecast, HLS, RTMP, UDP and SRT streams — and prefer TCP transport on busy Wi-Fi. --doctor checks that the address resolves and its port answers.
“Watch a folder” mode
If you already have a pile of recordings, BirdNet-Behavior can classify a folder of audio files instead of listening live — handy for batch-processing field recordings.
Next: Configuration to wire your choice in, then First Steps.
Configuration
A setting can come from four places. When more than one names the same setting, this is the order (highest wins):
CLI flag or its BIRDNET_* variable > the Settings page > /etc/birdnet/birdnet.conf > built-in default
That is one rule, and the rest of the manual means it: the Settings page says the same, and the code that does it is src/cli.rs and src/helpers/settings_overlay.rs.
- A CLI flag and its
BIRDNET_*variable are the same setting (the binary reads the variable when the flag is absent), so set one or the other. Either counts as explicit and beats the Settings page for good: a Docker station started with-e BIRDNET_SEGMENT_DURATION=30keeps 30 whatever that field of the page says. - The Settings page is the SQLite
settingstable, edited at/admin/settings; it is also the canonical place for everything that has no flag or variable — the detection confidence threshold, per-species thresholds, sensitivity, email SMTP, quarantine rules, and so on. You do not need to touch any of these to get started. Each key inbirdnet.conf(and each value passed as a flag or variable at install time) is copied into the table the first time the station sees it — insert-only, so nothing you saved on the page is ever overwritten. Once a key has a row there, editing that key in the file changes nothing: the page’s value is the one used. (That is why changingLATITUDEin the file does not move a station that has already started once: change it on the page.) Most values saved on the page take effect at the next restart. birdnet.conf(kept for BirdNET-Pi compatibility) supplies the settings the page has no field for, and the first value of those it has.- A setting with no field on the page skips that level: the flag or variable, else the file, else the default.
Environment variables & CLI flags
The full list lives in birdnet-behavior --help, and .env.example documents every variable the binary reads; a test keeps the two and the code from drifting apart. Each row shows the environment variable, the matching CLI flag, and the birdnet.conf INI key (for BirdNET-Pi compatibility).
A key the station does not read is reported rather than ignored: a birdnet.conf line such as CONFIDENC=0.90, or an environment variable such as BIRDNET_LATITUD, is named at startup in the journal and by --doctor, with the nearest real name when one is close (“did you mean CONFIDENCE?”). Before this, a misspelt key was parsed, stored and silently left at its default.
| Env var | CLI flag | birdnet.conf key | Default |
|---|---|---|---|
BIRDNET_LATITUDE / BIRDNET_LONGITUDE | --latitude / --longitude | LATITUDE / LONGITUDE | — |
BIRDNET_ALSA_DEVICE / BIRDNET_ALSA_DEVICES | --alsa-device / --alsa-devices | ALSA_CARD / ALSA_CARDS | — |
BIRDNET_PIPEWIRE_DEVICE | --pipewire-device | — | — |
BIRDNET_RTSP_URL / BIRDNET_RTSP_URLS | --rtsp-url / --rtsp-urls | RTSP_URL / RTSP_URLS | — |
BIRDNET_LISTEN | --listen | — | 0.0.0.0:8502 |
TZ | — | — | UTC in the container. An IANA zone name (Europe/Berlin); the container files detections under this zone’s local hours, and warns at start when it is unset or not a zone the image knows. quickstart.sh writes the host’s zone into .env; compose sets no default of its own. On a host, the zone comes from the system (timedatectl set-timezone). |
BIRDNET_ANALYTICS_DB | --analytics-db | ANALYTICS_DB_PATH | <database>.duckdb beside the SQLite file; --analytics-db "" turns analytics off |
BIRDNET_SKIP_MIGRATION_BACKUP | — | — | unset. Any value but empty or 0 lets a history-rewriting migration run without the backup it otherwise takes first — for a station whose disk has no room for a copy of the database, accepting that the previous timestamps cannot be recovered |
BIRDNET_RECORDING_SCHEDULE | --recording-schedule | RECORDING_SCHEDULE | all-day |
BIRDNET_SEGMENT_DURATION | --segment-duration | SEGMENT_DURATION (BirdNET-Pi’s RECORDING_LENGTH is read as this when SEGMENT_DURATION is absent or blank) | 15 |
BIRDNET_OVERLAP | --overlap | OVERLAP | 0.0 |
BIRDNET_METADATA_MODEL | --metadata-model | METADATA_MODEL_PATH | set by the installer / entrypoint |
BIRDNET_METADATA_LABELS | --metadata-labels | METADATA_LABELS_PATH | set by the installer / entrypoint |
BIRDNET_SPECIES_ALIASES | --species-aliases | SPECIES_ALIASES_PATH | — (a tab-separated legacy<TAB>canonical scientific-name map, for species the geomodel and the classifier name differently and the automatic rules cannot connect — see tuning) |
| — | — | WEATHER_PROVIDER | open-meteo; also met-no (MET Norway, keyless), pirate-weather (needs WEATHER_API_KEY) and wunderground (your own personal weather station). An unimplemented name does not start the poll — see the weather overlay |
| — | — | WEATHER_STATION_ID | — (wunderground only: the station’s Weather Underground id) |
BIRDNET_WEATHER_API_KEY_FILE | — | WEATHER_API_KEY | — (wunderground and pirate-weather; mountable from a file like the other credentials) |
BIRDNET_EBIRD_API_KEY_FILE | — | EBIRD_API_KEY | — (absent ⇒ eBird corroboration is off entirely; mountable from a file like the other credentials — see eBird) |
| — | — | EBIRD_REGION | — (empty ⇒ the station’s own LATITUDE/LONGITUDE are used, which is usually better) |
| — | — | EBIRD_DIST_KM | 25 (0–50; the radius asked about when using coordinates) |
| — | — | EBIRD_BACK_DAYS | 14 (1–30; days of eBird history to ask about) |
BIRDNET_SF_THRESH | --sf-thresh | SF_THRESH | 0.03 (no effect without a metadata model) |
BIRDNET_PRIVACY_THRESHOLD | --privacy-threshold | PRIVACY_THRESHOLD | 0.0 (off). The model’s confidence for a human sound at or above which a window is suppressed (typically 0.01–0.03). Not BirdNET-Pi’s PRIVACY_THRESHOLD, which is a percentage: use PRIVACY_RANK below for BirdNET-Pi’s rule |
BIRDNET_PRIVACY_RANK | --privacy-rank | PRIVACY_RANK | 10 (0 = off, up to 12000). BirdNET-Pi’s human-sound rule: a window, and the windows either side of it, are suppressed when a human class ranks in the model’s top N classes, read before the detection threshold. On by default, including on a station that never set it. Either this rule or PRIVACY_THRESHOLD suppresses a window. A field on the Settings page (Privacy Rank, Detection); a flag or variable other than 10 beats it, while one left at 10 is the default and yields to the page. For a BirdNET-Pi PRIVACY_THRESHOLD of p, use max(10, 60 × p) here and 0 there — see Same audio, same detections |
BIRDNET_NOISE_THRESHOLD | --noise-threshold | NOISE_THRESHOLD | 0.0 |
BIRDNET_NOISE_CLASSES | --noise-classes | NOISE_CLASSES | Dog |
| — | — | NOISE_REMEMBER_SECS | 0.0 (off; keeps suppressing the species a bark produced for this long after it — see tuning) |
BIRDNET_CONFIRMATION_LEVEL | --confirmation-level | CONFIRMATION_LEVEL | off (needs OVERLAP) |
BIRDNET_OFFSITE_BACKUP | --offsite-backup | OFFSITE_BACKUP | off; also s3, sftp and rsync (the --help text lists three) |
BIRDNET_OFFSITE_PASSPHRASE | — (deliberately no flag) | OFFSITE_PASSPHRASE | — |
BIRDNET_OFFSITE_KEEP | — | OFFSITE_KEEP | 8 |
BIRDNET_OFFSITE_S3_ENDPOINT | — | OFFSITE_S3_ENDPOINT | — |
BIRDNET_OFFSITE_S3_BUCKET | — | OFFSITE_S3_BUCKET | — |
BIRDNET_OFFSITE_S3_PREFIX | — | OFFSITE_S3_PREFIX | (none) |
BIRDNET_OFFSITE_S3_REGION | — | OFFSITE_S3_REGION | us-east-1 |
BIRDNET_OFFSITE_S3_ACCESS_KEY | — | OFFSITE_S3_ACCESS_KEY | — |
BIRDNET_OFFSITE_S3_SECRET_KEY | — (deliberately no flag) | OFFSITE_S3_SECRET_KEY | — |
BIRDNET_OFFSITE_S3_ADDRESSING | — | OFFSITE_S3_ADDRESSING | auto |
BIRDNET_OFFSITE_SFTP_HOST | — | OFFSITE_SFTP_HOST | — |
BIRDNET_OFFSITE_SFTP_PORT | — | OFFSITE_SFTP_PORT | 22 |
BIRDNET_OFFSITE_SFTP_USER | — | OFFSITE_SFTP_USER | — |
BIRDNET_OFFSITE_SFTP_DIR | — | OFFSITE_SFTP_DIR | — |
BIRDNET_OFFSITE_SFTP_IDENTITY | — | OFFSITE_SFTP_IDENTITY | — |
BIRDNET_OFFSITE_SFTP_KNOWN_HOSTS | — | OFFSITE_SFTP_KNOWN_HOSTS | beside the identity file |
BIRDNET_OFFSITE_SFTP_HOST_KEY_POLICY | — | OFFSITE_SFTP_HOST_KEY_POLICY | yes |
BIRDNET_DUPLICATE_INTERVAL_SECS | --duplicate-interval-secs | DUPLICATE_INTERVAL_SECS | 0 |
BIRDNET_NIGHT_FILTER | --night-filter | NIGHT_FILTER | off |
BIRDNET_NIGHT_MARGIN_MINS | --night-margin-mins | NIGHT_MARGIN_MINS | 60 |
BIRDNET_NIGHT_EXTRA_NOCTURNAL | --night-extra-nocturnal | NIGHT_EXTRA_NOCTURNAL | — |
BIRDNET_NOTIFY_URLS | --notify-urls | NOTIFY_URLS | — |
BIRDNET_APPRISE_URL | --apprise-url | APPRISE_URL | — |
BIRDNET_NOTIFY_CONFIDENCE | --notify-confidence | APPRISE_MIN_CONFIDENCE | 0.8 |
BIRDNET_DEADMAN_HOURS | --deadman-hours | DEADMAN_HOURS | 24 (0 = off) |
BIRDNET_BIRDWEATHER_TOKEN | --birdweather-token | BIRDWEATHER_TOKEN | — |
BIRDNET_BIRDWEATHER_URL | — | BIRDWEATHER_URL | public BirdWeather |
BIRDNET_MQTT_HOST | --mqtt-host | MQTT_HOST | — |
BIRDNET_MQTT_HA_DISCOVERY | --mqtt-ha-discovery | — | disabled |
BIRDNET_MAX_FILES_PER_SPECIES | --max-files-per-species | MAX_FILES_SPECIES | 0 |
BIRDNET_CLIP_RETENTION_DAYS | --clip-retention-days | CLIP_RETENTION_DAYS | 0 (keep forever) |
BIRDNET_DISK_PURGE_THRESHOLD | --disk-purge-threshold | DISK_PURGE_THRESHOLD | 95 (BirdNET-Pi calls it PURGE_THRESHOLD) |
| — | — | FULL_DISK | purge. What happens at that threshold: purge deletes the oldest clips of the most-recorded species (never below PURGE_SPECIES_FLOOR, never a locked one); keep stops recording, deletes nothing and records again by itself once usage is 2 points below the threshold, as BirdNET-Pi’s FULL_DISK=keep does |
BIRDNET_PURGE_SPECIES_FLOOR | --purge-species-floor | PURGE_SPECIES_FLOOR | 5 (0 = no floor) |
BIRDNET_STREAM_RETENTION_SECS | --stream-retention-secs | STREAM_RETENTION_SECS | 600 |
BIRDNET_STREAM_MAX_MB | --stream-max-mb | STREAM_MAX_MB | 512 |
BIRDNET_RAW_AUDIO_KEEP_EVERY | --raw-audio-keep-every | RAW_AUDIO_KEEP_EVERY | 0 (keep no raw audio; 1 keeps every segment, N one in N) |
CADDY_PWD / CADDY_USER | — | CADDY_PWD / CADDY_USER | CADDY_PWD auto-set on bare-metal install; sign in as admin (CADDY_USER is environment-only — see Remote access) |
BIRDNET_PRIVATE_MODE | --private-mode | PRIVATE_MODE | off (viewing is open). true puts the whole station behind the sign-in; needs CADDY_PWD, or everything but the sign-in answers 503. See Hardening |
BIRDNET_PUBLIC_ACCESS | --public-access | PUBLIC_ACCESS | — (nothing carved out). Comma-separated live_audio, share, metrics; no effect without private mode |
BIRDNET_CORS_ALLOWED_ORIGINS | — | — | — (same-origin only) |
Invalid settings are not run. On startup the station validates the configuration. If a value is out of range (e.g. a latitude outside ±90, or a malformed
RECORDING_SCHEDULE), it runs on the last configuration that passed, or — when there is none — starts web-only, recording nothing, so the dashboard can still tell you what is wrong. Both are reported in the journal and bybirdnet-behavior --doctor; run the doctor on a config before deploying it, or apply it with--apply-config, which refuses a file that does not validate.
BIRDNET_LISTENbinds all interfaces by default (0.0.0.0:8502), so the dashboard is reachable across your LAN out of the box. Viewing is open; anything that changes something requires a password — the/adminpanel, and also deleting, relabelling, reviewing or locking a detection, or saving the onboarding wizard. A fresh bare-metal install auto-generatesCADDY_PWD(usernameadmin) and prints it once in the post-install summary — change it viaCADDY_PWDinbirdnet.conf. To restrict the dashboard to the local machine, setBIRDNET_LISTEN=127.0.0.1:8502. The authentication (CADDY_PWD) and cross-origin (BIRDNET_CORS_ALLOWED_ORIGINS) settings are covered in Remote Access & Security.
What the station connects to
Two connections are made on the station’s own initiative, with no configuration:
| Host | Why | Turn it off with |
|---|---|---|
api.github.com | Once 60 s after start and every 24 h after: checks whether a newer release exists and logs the answer. It never installs anything; an update is applied with sudo bash install.sh update (a systemd station cannot replace its own binary, so the admin panel does not try). | --no-update-check / BIRDNET_NO_UPDATE_CHECK=1 |
en.wikipedia.org, upload.wikimedia.org | Downloads a photo the first time a species is detected, then serves it from the local cache for ever. | --image-cache-dir "" |
One more can happen once, on a first run: if the bundled
duckdb-behavioral extension cannot be loaded from the local cache or the
copy embedded in the binary, DuckDB tries the community registry
(extensions.duckdb.org). Release binaries carry the extension, so this
normally never fires.
Everything else is off until you configure it, and configuring it is the
consent: Apprise, BirdWeather, MQTT, SMTP e-mail, the heartbeat ping, and the
weather poll (api.open-meteo.com by default; api.met.no, api.weather.com
or api.pirateweather.net for the other WEATHER_PROVIDERs — itself opt-in via
BNB_WEATHER_ENABLED=1).
The location button calls ipwho.is (and get.geojs.io if that does not
answer), over TLS and only when you press it. The request tells that service
this station’s public IP address, which is how it finds the area; no station
data is sent. You review the coordinates before saving them.
Offline mode
BIRDNET_OFFLINE=1 # or: --offline
Turns off both default-on connections at once — the update check and the image downloads — so the station makes no outbound connection you did not ask for. Integrations you configured explicitly keep working: offline mode is about unsolicited traffic, not about muting your alerts.
Use it on metered or cellular links, on air-gapped deployments, and wherever “what does this contact?” needs a single answer. Already-cached species images are still served; only new downloads stop.
birdnet-behavior --doctor reports the current posture under Outbound
connections, so you can confirm it rather than infer it.
Web-UI-only settings
These are stored in the SQLite settings table and have no environment variable or command-line flag; a few also have a birdnet.conf key, noted beside them. That key is copied into the settings table the first time the station sees it; after that the value saved on the page is the one the station uses. Set them at /admin/settings — see Settings & Detection.
| Setting | Where | Note |
|---|---|---|
| Detection confidence threshold | Detection | Also CONFIDENCE in birdnet.conf; default 0.70 (BirdNET-Pi’s; it was 0.75 up to v0.17.1). Per-species overrides also live here |
| Detection sensitivity (0.5–1.5) | Detection | Also SENSITIVITY in birdnet.conf; default 1.25. It means what it means in BirdNET-Pi on a V2.4 model and is ignored by V3.0 — see Settings |
| Email / SMTP notifications | Notifications | |
| Rare-bird quarantine rules | Species | |
| BirdWeather station details | BirdWeather | Token can also be set via env var |
Retention is off by default. Nothing is deleted by age unless you ask for it: set Keep Clip Audio (days) in the UI (
CLIP_RETENTION_DAYS) to reclaim the audio of older detections. Independently of that, the disk manager purges the oldest recordings once the disk hitsDISK_PURGE_THRESHOLD(default 95%) and keeps at mostBIRDNET_MAX_FILES_PER_SPECIESper species. Pin clips you want to keep with the lock action on any detection — locked clips are never purged by either limit.All three limits, plus the temporary streaming folder’s retention and size ceiling, can be set from Settings → General → System & Display in the web UI; you never have to edit a config file to change them. A value given on the command line or as a
BIRDNET_*variable wins over the UI, which in turn wins over the config file (the order at the top of this page).When a clip is purged the detection itself is kept — its filename and the date the audio was reclaimed stay on the record, so counts, species lists, trends and exports are unaffected. Only the audio goes.
First Steps
After starting (via the installer or Docker), open the web dashboard:
http://<your-ip>:8502
Not sure of your Pi’s IP? Run
hostname -Ion the Pi, or check your router’s device list.
The dashboard binds to all interfaces by default, so it’s reachable from any device on your LAN — no extra step. Viewing needs no login; the Admin panel, the Settings management tabs and every action that changes something do. The bare-metal installer prints an auto-generated admin password once in its post-install summary (username admin) — log in with that when you open /admin. Lost it? See Remote Access & Security to read or reset CADDY_PWD. To restrict the dashboard to the Pi itself, set BIRDNET_LISTEN=127.0.0.1:8502.
If you set your latitude/longitude and an audio source, detections start appearing within a minute or two of the first bird call — no further configuration required.
A two-minute tour
The UI has six homes — the tabs along the top (and the phone bottom bar):
- Today (
/) — the home and right-now view: live signal, the day strip, and the full searchable detection log. See Today. - Species (
/species) — browse every species you’ve recorded as a list, a photo grid or your growing life list. See Species & the Life List. - Patterns (
/patterns) — the analytics: when birds are active, the dawn chorus, migration, who sings together, trends. See Patterns. - Recordings (
/recordings) — play back captured clips and listen to live audio. - Reports (
/reports) — the weekly recap, year in review and history calendar. See Reports. - Settings (
/station) — health at a glance, plus all configuration and admin. Start with General (/station/settings) for optional fine-tuning: confidence threshold, per-species overrides, email, quarantine rules.
First-run wizard
You do not need a terminal, a config file or a command-line flag to get a new station detecting. Open /onboarding (a fresh station is sent there automatically the first time you open the dashboard) and answer a seven-step wizard — about two minutes:
- Welcome — on a station with no admin password (a Docker run without
CADDY_PWD, say) this step asks you to choose one, and the browser that finishes setup is signed in with it. If the installer generated a password for you, you are asked to sign in first and then land on the wizard. - Model — the bird-identifying model is a download of about 54 MB that the program does not ship. This step says whether it is installed; if not, one button downloads and checks it, with a progress bar. A download that fails says why and can be retried.
- Location — latitude and longitude (with a lookup from a place name), the station’s name, and the language bird names are shown in. The location it saves must be a real coordinate pair (both values, within ±90 / ±180) and a time zone this host knows, or it is left unset and the server log says why.
- Microphone — the sound cards the system lists that can record are offered by name, with a Test button that records two seconds and shows how loud it was. A network camera’s address (RTSP) or a device name can be typed instead. What you choose becomes the same audio source Settings → Capture shows.
- Accuracy — plain-language presets for the minimum confidence a detection needs before it is recorded. The default is 0.70, BirdNET-Pi’s own default (it was 0.75 up to v0.17.1), so that a station nobody has customised stores the rows BirdNET-Pi stores for the same audio. Change it later under Settings → Capture → Detection Settings, where per-species thresholds also live.
- Alerts — plain-language presets for when to be notified.
- Done — saves everything and shows what is true now: whether the model is installed, which microphone was chosen, and the one button that restarts the station so it starts listening. A station that is not run by a service manager restarts itself in place; no terminal is needed.
Everything the wizard shows is read from the station itself — if a read fails the page says so instead of drawing a wizard from defaults that would overwrite your real settings. If nothing is configured as a microphone, the final page says so plainly, because a station with no source detects nothing.
After the wizard
Every setting can be changed later from Settings in the browser. Each setting carries a badge saying whether it applies immediately or after a restart; when something is waiting for a restart, a banner at the top of Settings says which settings and offers the restart button. The complete list of where each setting lives — and the few that are deliberately file-only, with the reason — is in Where to change it.
Health check
At any time you can run the built-in diagnostic, which prints a one-screen report covering CPU, configuration, audio reachability, the model file, database integrity, disk, and network — each problem with a concrete suggested fix:
# Bare metal: the service runs as the account that ran the installer (there is
# no "birdnet" user); this prints that account and runs the doctor as it
sudo -u "$(systemctl show -p User --value birdnet-behavior)" birdnet-behavior --doctor
# Docker
docker compose exec birdnet birdnet-behavior --doctor
See Troubleshooting if anything looks off.
Today
Today (/) is the home — the page you leave open. It answers “what’s
happening in the yard right now?” before you ask, and it’s the merge of what
used to be two separate screens (the Dashboard and the Today log): the live
“right now” view and the full searchable day are now one calm page.
Coming from an older build (or from BirdNET-Pi)? The old
/todayaddress still works — it redirects here.
The five layers
1 · The glance. The hero leads with a plain-English, comparative headline — “A busy morning.” — computed from today’s count against your last 30 days, so a quiet day and a record day read differently at a glance. Beside it, the live signal card shows the last 30 seconds of audio. It is honest: it animates only while real audio is arriving from the capture device and falls back to a flat baseline labelled idle when nothing has been heard recently — never a fake waveform. A row of pills carries the recording state, current weather, sunrise/sunset, and your station’s name and coordinates. If the station cannot read its list of audio sources, the recording pill says the state is unknown rather than claiming there is no microphone.
2 · The nudge (only when it matters). A calm strip appears under the hero only when something needs your eye: rare detections waiting in the review queue, or an outage warning when capture has gone quiet during daylight. When all is well and quiet, it’s absent entirely. If the station cannot read the review queue, the strip says so instead of staying absent, because absent is how “nothing is waiting” looks.
3 · The shape of the day. The day strip plots the whole day on one 24-hour timeline — an hourly histogram of how busy each hour was, an amber temperature line overlaid on the same axis, labelled sunrise/sunset markers, and a “now” line. The header carries the day’s peak hour, dawn-chorus count and total.
4 · The heartbeat. One unified log. The live feed streams new
detections as they arrive — each row a time, a species avatar (its four-letter
banding code in the species’ own colour), the names, a mini call-waveform, the
confidence, and a play button; a freshly arrived detection rises in with a
brief moss pulse. The full day sits behind one “Show the full day”
disclosure, where you can search (prefix NOT to exclude), filter by Rare /
First today / High confidence, and lock, re-label or delete any
detection. The list auto-refreshes, so a page left open stays current.
5 · The support rail. Today’s top species (with sparklines), the day’s best recordings, a one-line station-health readout, and quiet “looking back” links into Reports.
Arranging the page
A station on a kitchen tablet or a visitor-centre wall rarely needs everything
an operator debugging a microphone does. The Today Page Layout section at the foot of the full settings page
(/admin/settings, the Settings entry of the admin menu) lists the page’s parts in their current
order, each with Move up, Move down and Show/Hide. Every press is
saved at once.
Parts move within their own area: the headline, the nudge and the day strip among themselves at the top; the top-species, best-recordings and station cards within the side column. The detection log keeps its place and can only be hidden. Hide the log and the side column takes the full width; hide every side card and the log does. The order is the page’s real order, so a screen reader and the Tab key follow what is on screen.
The list also holds Your picture: one photograph of the site, or the last frame of a camera, with a title, between the day strip and the log. It is empty, and so invisible, until you give it a file under Settings → General → System & Display → Today page picture (see Settings).
A new station shows everything until its first detection, so the setup checklist in the headline cannot be hidden by accident.
First run
A brand-new station — one that has never heard a bird — shows a friendly “getting ready” checklist in place of the live signal (microphone detected, model loaded, disk headroom, listening…) plus illustrated empty states, so the very first hour is an activation moment rather than a blank page. The page comes alive the moment the first call lands.
Light & dark
The whole UI honours your operating system’s colour-scheme preference and remembers any manual override. Dark mode is a cool “observatory” black tuned so the moss and dawn accents glow rather than muddy.
Every page carries the same top navigation, theme toggle and global species search, so you’re never more than one click from any home.
Species & the Life List
What the Species home shows — the list of every bird the station has recorded, how to browse it by taxonomy, the per-species page and the life list — and how to read each; for anyone, hobbyist or researcher, who wants to browse or review what the station has heard.
Species list
The Species page (/species) is the browsable index of every bird you’ve recorded — searchable and sortable by most-heard, A→Z, or newest.
Each row carries the species avatar, common and scientific name, all-time count, a 14-day sparkline, average confidence, and first/last-heard dates.
Browsing by taxonomy
When the station has its classifier label file, the List and Photos views carry a row of order chips — Piciformes, Strigiformes, Anseriformes — with the number of your species in each. Clicking one narrows the page to that order; clicking All orders widens it again. The chips are built from the species you have recorded, not from the classifier’s 11 560, so a garden with forty birds gets a handful of orders rather than seventy-five, and every chip leads somewhere.
The chips and the search box compose: search inside an order, or pick an order while a search is active, and neither drops the other. The view switcher keeps the order too, so List → Photos stays on the same birds.
Three ranks are browsable — class, order and genus — and each is a link on the species detail page, so “the other Dryobates I’ve heard” is one click from a woodpecker. Genus has no chip row: 2 907 genera is not a control.
There is no family. The shipped label file states a class and an order and nothing between them (its header is idx;id;sci_name;com_name;class;order), so a family would have to be inferred from the genus, and a guess sitting beside two stated facts is worse than an absent rank. A station whose label file has no taxonomy columns at all — the V2.4 text format has none — simply gets no chips, and its pages are exactly as they were.
Species detail
Click any species for its detail page (/species/detail?name=…): a full-bleed Wikipedia photo, the scientific name and description, its class · order · genus (each a link back to the list filtered to that rank), an hourly activity profile (columns from zero, the busiest hour labelled, a tooltip for every hour), the last 14 days as columns against the average, Through the year (a calendar with a cell for every day of the past 365: how many times it was heard, with the days the station was not listening hatched so they are not read as days the bird was quiet), companion species (the birds it’s most often heard alongside), and a strip of recent recordings.
Why isn’t a bird heard here?
A bird you can hear and the station never logs is, sometimes, a bird the station’s range filter drops. The station asks a range model which species occur around where it is in each week of the year and holds back any it scores below the Species Frequency Threshold (default 3 %); see the settings. Until now nothing said which birds that was.
The link Why isn’t a bird heard here? at the top of the Species page (/species/occurrence) is the answer. Type a bird’s name, in English, in the scientific form or in the station’s own language, and it says in a sentence whether the station admits that bird this week, with the numbers: “Held back: the range model gives it 0.4 % here in week 22; this station admits species at 3 % and above.” A bird the model scores well but that your own include or exclude list keeps out is said to be held back by your lists, not by the model, and a bird under the threshold that you put on the Always allowed list is said to be allowed by it. The week defaults to the current one; the selector goes through the model’s 48-week year, four weeks to a month, so you can ask what the station would admit in May. Below the answer is the list of the birds the station does admit that week, a hundred at a time.
When a species page is for a bird the filter would hold back this week, a line under its headline says so and links to the answer for that bird.
If there is nothing to show, the page says why instead of showing an empty list, which would read as “nothing occurs here”: the station has no location, no range model is loaded (so the filter is off and every bird the classifier knows is a candidate, wherever the station is), or the model would not run. The same answer is available to scripts as GET /api/v2/species/occurrence. The numbers are the filter’s own: the species the page marks admitted are exactly those the detection daemon lets through for that place and week, and a test holds that against the real V2.4 range filter.
The station’s location is shown to one decimal place (about 11 km): enough to say which region’s birds the question is about.
Life list
The Life List (/species?view=lifelist — the old /life-list still redirects in) is your birding journal — every species, once. It leads with three tallies (species, total detections, active days) and a smooth accumulation curve showing the list growing month by month (a dot in each month that added a bird; point at a month for the total so far and how many came that month), then a per-month “new species” chart and the full ranked table with average-confidence pills.
Confidence pills and bars use one colour rule everywhere in the app: green at 90 % and up, amber for a detection within ten points of the station’s own cut-off (borderline), neutral in between. Because the edge follows your cut-off, a station that keeps 80 % and up has two tiers only: everything under 90 % reads as borderline. A key above each list says which applies, and none of the colours is the alert red.
Patterns
Patterns (/patterns) is the analytics home — “when & where?”. It gathers
the station’s deeper readings behind one tab strip, each tab leading with a
single picture and a plain-English read, with the dense numbers tucked behind a
“see the numbers” disclosure. A hobbyist never meets six analytical surfaces at
once; a researcher still reaches every one in a click.
| Tab | Question it answers | Covered in |
|---|---|---|
| When active | when are they out? | the soundscape terrain, the hour × day-of-week heatmap + by-hour totals |
| Dawn chorus | who sings, and when? | The Dawn Chorus |
| Migration | arriving & leaving | Migration & Phenology |
| Who sings together | who co-occurs? | the co-occurrence chord diagram (a ladder matrix on a phone) |
| Trends | busier or quieter? | detections a day against their 7-day average and the 30 days before, and species richness, each with the busiest and quietest day named |
| Behavior | the research tier | Behavioral Analytics |
The old standalone addresses still work and redirect into the right tab:
/heatmap→When active,/analytics/dawn-chorus→Dawn chorus,/migration→Migration,/correlation→Who sings together,/timeseries→Trends,/analytics→Behavior.
Every chart is rendered server-side as inline SVG — no JavaScript charting runtime — so the page stays light on a Raspberry Pi and works offline. The three deepest readings have their own pages, linked above.
The soundscape terrain
The When active tab leads with the station’s last 30, 90 or 365 days as a landscape: the hours of the day across, the days down (the newest at the bottom), and the colour of each patch how much was heard in that hour of that day. It opens as a flat grid of those numbers. 3D terrain raises the same numbers into a height-field you can turn: drag (or one finger) to orbit, scroll after clicking it or pinch to zoom, the arrow keys to turn, plus and minus to zoom, Home to put it back, Shift with the arrows to move a reading cursor. Colour by switches from how much to which species was heard most in each hour.
Height and colour are the square root of the count, so a quiet hour beside a busy
one is still there. A day the station was not listening, and an hour that has not
happened yet, are flat and hatched: that is not the same as quiet. The 3D view
loads its one library (a part of three.js, about 134 kB compressed) only when you
choose it; a browser without WebGL keeps the grid and says so. Every reading is
written under the picture and the numbers are in a table behind “See the numbers”.
Design notes and the checks behind it: docs/design/charts-relational.md.
Behavioral Analytics
BirdNet-Behavior turns a stream of detections into behavior. The analytics screens answer when the yard is alive and who sings with whom.
Activity heatmap — “When the yard is alive”
The When active and Dawn chorus tabs of Patterns (/patterns) stack several bespoke visualizations:
- Activity streamgraph — species composition over time, drawn from a centered baseline. Each band is a species and its thickness is that day’s count; the eight busiest are drawn one by one and every other species is one more band, so the whole is the day’s real total. Point at a day (or use the arrow keys) for every species’ count; point at a band, or at a name in the key, to bring that species forward and put the rest in the background. The curves between days are smooth but never rise above a peak or below a trough the counts did not have. A day the station was not listening is hatched, so it is not read as a silent day.
- Activity grid — an hour × day-of-week mosaic of rounded cells on the sequential sand-to-brick ramp (one hue family, steps by the square root of the count so one detection and ten are told apart). The colour is the average per day in that slot, so a 90-day window’s thirteen Mondays are compared with its twelve Tuesdays fairly. The totals for each weekday are beside the grid and the totals for each hour are under it; the busiest cell is ringed and named. Three states are kept apart: a count is a colour, a quiet hour is an empty cell, and a weekday the station was off for is hatched; an hour of today that has not happened yet is an outline that says “Not yet”.
- Circadian rhythm — a polar plot where each species occupies its own concentric ring that swells where it’s active across the 24-hour clock, with a sky strip (night, first light, day), three-hour ticks, the sun and a solid “now” hand; see The Dawn Chorus.
- Detections by hour — the day’s totals as columns on an axis from zero, with the night shaded behind them (from your station’s sunrise and sunset) and dawn and dusk in colour. It is a bar chart rather than a clock because the question is how much more: length on a common baseline is read more accurately than an arc, and the dawn chorus tab already has the clock for who sings when. Only the busiest hour is labelled; point at any hour for its count, its share of the day and, on Today, its three busiest species.
- Seasonal phenology — a migration ridgeline, one gradient-filled ridge per species, with spring and fall season bands behind; see Migration & Phenology.
Pointing at a chart
Every chart here answers to a pointer, a finger and a keyboard alike. Point at it (or touch it, or Tab to it and use the arrow keys) and a small card reads out what is under you: the day and every species’ count, the hour and its share of the day, the cell and its count. Left and Right step along the chart, Up and Down step by row (the grid), by week (the calendar) or from species to species (the streamgraph), Escape lets go, and each step is read aloud to a screen reader. The same figures are always in the table under the chart, behind “Show as a table”. Nothing moves when you do this, and with reduced motion on (in your system, or under Settings → General → Display → Motion) the charts simply appear instead of growing into place. The reasoning behind how these charts are drawn, and what they refuse to do, is in docs/design/charts.md.
Co-occurrence — “Who sings with whom”
The Who sings together tab of Patterns (/patterns?tab=together) shows which species are detected together — heard in the same five-minute block of the same day. A pair’s strength is its overlap: the blocks they share, as a share of the blocks in which either was heard. A pair needs at least three shared blocks to be drawn.
- The acoustic network — the data drawn as a chord diagram: each species an arc, each pair that is heard together a ribbon whose width and colour are how much of their time they share. The species stand in the order that crosses the fewest ribbons, so groups that sing together sit together. It shows the 12 strongest links (choose 24 or All). Point at a name and only its ribbons stay in the foreground; point at a ribbon and only it and its two species do. The ring needs a wide screen: on a phone you get the ladder instead, a staircase matrix with each name at the foot of its own step (read across a row to its name, up a column to the other).
- Co-occurrence matrix and strongest pairs — the same numbers as a table behind “See the numbers”.
- Top co-occurring species and a companion lookup let you ask “what’s usually nearby when I hear X?”
The richest DuckDB-powered views — activity sessions, species retention, next-species prediction, year-on-year trends — are on by default: every release has the analytics engine built in, and the installer and Docker compose enable it automatically. If you’ve turned it off on a low-RAM board, these panels explain what they’d show and how to switch it back on. See Configuration.
Behavior — the research tier
The Behavior tab of Patterns (/patterns?tab=behavior) draws what the analytics engine computes; each picture keeps its numbers in a table.
- Bursts of singing — a lane per day with the hours along it and a bar for each burst, from the moment it began to the moment it ended; bursts of different species that overlap are stacked, never drawn over each other. The legend brings one species forward.
- Who keeps coming back — species grouped as resident, regular, migrant and rarity (how much of the station’s time they were present), with the weeks each was heard and how often it was heard again a day, two, three, a week, a fortnight and a month later, each a cell that also prints its number.
- Neighbouring species — who is heard just before and just after the latest species, as a flow with that species in the middle (ranked bars on a phone); a ribbon’s width is the share of sessions.
- The morning running order — a lane per voice of your dawn, the clock from 04:00 to 09:00 along it, a dot for every morning’s first-heard time, thin lines joining the newest mornings from lane to lane, a tick at each voice’s typical time and the newest complete morning drawn large. A line that slants steadily to the right as it goes down is a morning in the usual order.
Migration & Phenology
The Migration tab of Patterns (/patterns?tab=migration) turns a year of detections into a picture of when each species moves through your yard. It’s the seasonal companion to Today’s right-now view: instead of “what’s singing this minute?”, it answers “who has arrived, who’s peaking, and who’s overdue?”
What you’re looking at
- Four KPI tiles across the top:
- First-of-year arrivals — species whose first-ever detection at the station falls in the current year.
- Peak diversity week — the calendar week with the most distinct species so far.
- Earliest vs last year — of the birds that arrived in both this year and last (first heard from the end of January on), the one that came furthest ahead of its arrival last year, in days (a negative number means earlier). It needs a last year that was recorded from its start: a station that began part-way through it says “last year was only partly recorded” rather than comparing a first date that is only where recording began.
- Still expected — species that arrived within the next six weeks of the prior year and haven’t been logged yet this year. It says “needs a year of records” where there is no last year to expect from.
- The ridgeline (“joyplot”) is the heart of the page: one ridge per seasonal species, stacked and ordered by peak week, a ridge being the species’ detections each week as a share of its own busiest week. A ridge’s colour is the season it peaks in (green for the spring migration window, amber for the fall one, grey for neither; the windows swap south of the equator). Point at a week to read every species heard in it with its real count and how many different species were heard; the weeks that have not happened yet are hatched, not drawn as zero.
- The species-per-week lane under the ridges is distinct species heard each week, on the same weeks.
- Editorial cards call out what just arrived, what’s currently peaking, and what is missing: the birds that arrived in these weeks last year and have not been heard yet.
How a species qualifies as “migratory”
The ridgeline uses a deliberately cheap heuristic on the current year’s data: a species earns a ridge when its busiest week is at least three times its median active week (a sharp seasonal pulse), holds at least five detections, and it has been heard across at least four weeks. When more than twelve qualify, the twelve most sharply seasonal are drawn. Year-round residents — which detect at a steady level — are filtered out so the plot stays about movement, not abundance. Early in the year the page is naturally sparse; it fills in as the season progresses.
This is a station-local view built entirely from your own
detectionstable — no external range maps. It reflects what your microphone hears, which is exactly the point.
Not the same as “Migrating from BirdNET-Pi”
This page is about bird migration through your yard. If you’re looking to import data from an existing BirdNET-Pi install, that’s a different feature — see Migrating from BirdNET-Pi and the import panel under Settings → Data (/station/data#import; the old /admin/migrate redirects there).
The Dawn Chorus
The Dawn chorus tab of Patterns (/patterns?tab=dawn) shows the daily rhythm of your yard: which species sing when, wrapped around a 24-hour clock and anchored to the real sunrise and sunset for your location.
What you’re looking at
- The rings plot detection activity around a 24-hour dial (midnight at the top, noon at the bottom). Each species is a coloured ring that swells outward at the hours it was heard, scaled to its own busiest hour so a quiet wren reads as clearly as a loud cardinal. Point at a ring (or tab to it and use the arrow keys: left and right move by the hour, up and down from ring to ring) and it comes forward and says the hour, the count, that hour’s share of the bird’s day and its busiest hour. A swell sits on the middle of its hour: a bird heard through 06:00-06:59 peaks at 06:30.
- The sky strip round the rim is drawn from the station’s coordinates: night, first light (civil dawn), daylight and last light, with the sun where the colour changes. The times are in the key beside the chart. Birds start singing in first light, so it is drawn beside sunrise.
- The middle says the time now (the line from the centre is the same hand) and the window the rings are drawn from (60 days); the four arcs round it are the moon’s phase.
- The key beside the chart names every ring with its busiest hour; hover or focus a name to bring its ring forward, click to hold it.
- Each bird’s day lays the same species flat: one row each, a column per hour, the dawn window shaded and the sun’s day a line. The numbers are in a table under it.
Setting your location
The sunrise/sunset markers use the station’s configured coordinates — the
latitude / longitude settings you enter in the onboarding wizard or under
Location & Recording Schedule on the settings form. With no coordinates the
ribbons still draw, but the sun markers and the night wedge are omitted rather
than guessed (see below). BNB_STATION_LAT / BNB_STATION_LON are read only by
the weather integration and have no effect here.
A note on time zones
Everything on this clock is in the station’s local time: the ribbons are bucketed from the local hour in each recording’s filename, and the sunrise/sunset markers are computed for your configured coordinates and then shifted into the same local frame. Set your station’s timezone with timedatectl set-timezone (which is what --doctor checks) and set your coordinates on the Settings page — the two together are what makes the sun markers land where the sun actually was.
This page previously told you to run the station on UTC. That was advice for a defect, not a design: the markers were computed in UTC and drawn over local-time ribbons, so on any non-UTC station they were offset by the UTC difference, and they contradicted --doctor, which has always told operators to set their local timezone. The markers were additionally computed for a hard-coded (40.0 N, 74.0 W) unless two undocumented environment variables were set, on a day-of-year that drifted about a day a year. All three are fixed; run the station on its real timezone.
No coordinates set? The ribbons still draw — they need no location — but the sun markers and the night wedge are omitted rather than guessed. A sun drawn where your station is not would answer “does this species sing before sunrise?” wrongly while looking authoritative.
Like the migration view, this is built entirely from your own detections — no external data.
Recordings & Live Audio
Recordings (/recordings) is the audio home — both the saved clips and the
live stream.
The home has two views, switched on the sub-tab row: Clips (browse what your station already caught) and Live (listen along in real time).
Clips (/recordings?view=clips)
Clips is a flat, newest-first list of every detection that saved an audio clip. Each row carries the time, the species (with its banding-code avatar) and a confidence bar; play it with the ▶ button, download it, lock it, or delete it.
- Filter chips narrow the list to All, Best (high-confidence), Rare (a confident first-ever record) or Locked clips, and the search box finds a species by name. The chips and search are real links, so any view is bookmarkable.
- Now playing rides the page header while a clip plays and floats up into a compact bar when you scroll past it, with a live progress scrubber.
- Lock (🔒) protects a clip from the disk purge — the same lock you can set from Today; locked clips also show under the Locked filter.
- Select turns on checkboxes and a bulk bar so you can lock, download or delete a whole batch at once.
Live (/recordings?view=live)
Open the Live view to listen along with your station in real time — a
per-source audio player and a scrolling spectrogram, with a live trickle of
detections as they’re classified. The spectrogram is honest: it scrolls
only while audio is arriving and shows a flat baseline otherwise, never a fake
waveform. Today’s live-signal card links straight here, and you can preselect a
microphone with /recordings?view=live&source=<id>.
The pre-spine
/listen,/livestreamand/liveaddresses still work — they permanently redirect to/recordings?view=live.
Species photos
The species Gallery now lives as the Photos view of the
Species home (/species?view=photos) — a photo-card grid of
every detected species, searchable and sortable. The standalone /gallery
address still works.
Each card shows the species photo (cached from Wikipedia, with attribution), the common and scientific name, the detection count, and a confidence pill. Species without a cached photo fall back to an on-brand placeholder tinted in the species’ own color and stamped with its banding code — so the grid stays cohesive even before images load.
All bird photos are cached locally from Wikimedia Commons under CC BY-SA, and the attribution is shown on each image as the license requires.
Reports
BirdNet-Behavior turns your data into editorial recaps you’d actually want to read. Reports (/reports) gathers the look-back in one place, behind three tabs.
Weekly
The Weekly tab (/reports) is a Sunday-paper recap of the week — total detections, species count, new arrivals, a daily-activity chart and the week’s leaderboard. Navigate between weeks with the prev/next controls.
Year in Review
The Year in Review tab (/reports?tab=year) is a celebratory recap of the
calendar year so far — 1 January to today. Every figure on it covers that
period and nothing earlier; on New Year’s Day it starts again from nothing.
- a display headline and four big-number tiles (detections, species heard, species new to your list — first heard on this station this year — and the busiest day),
- a calendar of the year — a cell for every day since 1 January, a column a week, coloured by how many detections that day (the busiest day is ringed and named; a day the station was not listening is hatched, so it is not mistaken for a quiet one) — that shows its rhythm at a glance,
- the year’s species leaderboard with banding-code avatars,
- a milestones strip (first voice of the year, most-heard species, busiest day, newest arrival),
- and a closing tally.
History calendar
The History tab (/reports?tab=history) is a calendar browser. Each day tile is intensity-coded by detection count; select a day to see its hourly activity and top species.
Reviewing Detections
BirdNet-Behavior classifies every clip automatically, but you are the final arbiter. The detection-review queue lets you record a verdict — confirmed or rejected — on individual detections, building a clean, human-checked record over time.
Reviews vs. quarantine
These are two different quality-control surfaces; it helps to keep them straight:
| Aspect | Quarantine | Detection reviews |
|---|---|---|
| What it holds | Detections that failed a stricter per-species threshold | Detections already admitted to the log |
| Effect | Gates rows out of detections until you approve them | A non-destructive annotation; nothing is moved or deleted |
| Verdict | Approve / Reject / Delete | Confirm / Reject (reversible) |
| Use it to | Vet uncertain rare birds before they count | Audit the ID quality of detections that already count |
A rejected review flags a likely misidentification for your own records — it does not remove the detection. Quarantine is the tool for keeping a dubious record out of the log entirely.
The triage queue (/detection-reviews)
The queue lists recent detections that have no verdict yet, newest first, each with its species, time, and confidence. Two buttons record a verdict:
- ✓ Confirm — the identification looks right.
- ✗ Reject — likely a misidentification.
A running tally at the top shows how many detections you have confirmed,
rejected, and have left to review. Recorded verdicts move to the Recent
verdicts list, where an Undo button clears a verdict and returns the
detection to the queue. Re-reviewing a detection simply replaces its previous
verdict — there is never more than one verdict per (date, time, species).
Reviewing from a detection
You don’t have to work the queue to leave a verdict. Every detection-detail page carries a Review this detection widget with the same Confirm / Reject buttons and a badge showing the current verdict, so you can judge a clip the moment you’re looking at it — spectrogram, audio and all.
Comments: why, not just what
A verdict says what the station decided. Six months later the question is why — and “call length says Downy, but the spectrogram is Hairy” is the sentence that makes a record defensible to somebody who was not there.
Under the review widget, every detection-detail page carries a Comments thread. Write a note, it appears with your username and the time, and it stays that way: a comment is never edited. The database refuses it — there is a trigger on the table that aborts any attempt to rewrite a comment’s text, author or timestamp — so what you read is what was written.
Two things follow from that, and both are deliberate:
- Many comments per detection. Two observers disagreeing is the point. This is the difference from the review widget’s own notes field, which is keyed on the detection and replaces whatever was there: a second reviewer’s note silently erased the first one’s reasoning, and neither of them was named.
- A comment can be withdrawn, not rewritten. Remove deletes it outright — which is what a note containing a typo, or a neighbour’s name, needs. The audit log records the removal, its author and its id, and never the text: a comment deleted because of what it said must not survive in the log that recorded its deletion.
Comments are up to 2 000 characters, and writing one needs an admin sign-in — the same gate as confirming or rejecting. Anyone who can see the detection can read the thread.
Scripts can join in through the API: GET, POST and
POST …/delete on /api/v2/detections/comments (see the
API reference). Those comments are attributed to api
rather than to a name the caller supplies — a bearer token is not a person, and
a name the caller chose is not attribution. A script with something to say
about who is speaking says it in the comment.
Sharing a detection for a second opinion
Not sure about a call? Both the detection-detail page and every row in the
quarantine queue have a Share button that copies a
public /r/<token> link. The share page
resolves quarantined rows too, so you can send a rare bird that hasn’t been
admitted to the log to another birder for a second opinion before you decide.
Detection Detail & Share Links
Every detection in the app — a feed row on Today, a point in a chart — links through to a detail page that shows everything known about that one moment, and lets you hand it to someone else with a single public link.
The detail page
/detections/detail?date=…&time=…&name=… is the canonical detail view. A detection has no numeric id; it is identified by the (Date, Time, Com_Name) triple, which is what the route’s three query parameters carry.
It shows:
- the species (common + scientific name), confidence, and exact timestamp;
- the 3-second clip, rendered as both a spectrogram image and an inline audio player, when a recording is on disk for that detection;
- the correlation id the detection daemon stamped on every log line, DB write and notification for the source file — copy it and
grepyour logs to pull the exact decode→infer→notify slice that produced the row; - the audio source that heard it (the stream label — e.g.
cam1orlocal— recorded on every detection since the multi-stream update); - an “also heard by” corroboration card on a multi-source station: when another stream detected the same species at nearly the same moment, it is shown here. Independent agreement across microphones is a stronger signal that a detection is real — useful when the identification matters, such as a rare-species record. The card is read-only and never merges or hides rows;
- a Share clip button;
- a Review this detection widget — confirm or reject the identification in place (see Reviewing Detections).
Share links (/r/<token>)
The Share clip button copies a public, self-contained URL of the form /r/<token> to your clipboard. Anyone with the link sees a clean, single-detection page — species, time, spectrogram and audio — with no navigation into the rest of your station and no login.
How the token works:
- It encodes
(date, time, com_name, expiry)and a truncated HMAC-SHA256 signature, base64url-encoded. Links default to a 30-day lifetime. - Verification is constant-time. A tampered or expired token renders a neutral “This clip is gone” page (HTTP 404,
noindex) that leaks nothing about whether the detection exists. /r/<token>/audio.wavand/r/<token>/spectrogram.pngresolve the recording by filename and serve it through the same handlers as the normal media routes, so the clip and spectrogram render without exposing any internal id — and without depending on those routes being reachable.
The signing key
With BNB_SHARE_SECRET unset — the usual case — tokens are signed with a key derived from the station’s own session secret, which is random and kept beside the database. Links therefore survive restarts, and changing that secret invalidates them.
Set BNB_SHARE_SECRET only to rotate share links on their own:
# 32+ random bytes
BNB_SHARE_SECRET="$(openssl rand -base64 48)"
A value shorter than 32 bytes, or one still containing CHANGE-ME, is ignored with a warning and the derived key is used instead.
Share links are deliberately read-only and scoped to one detection. They are not affected by — and do not bypass — the admin password or any reverse-proxy auth in front of the main UI. On a station in private mode they are behind the sign-in like everything else unless
BIRDNET_PUBLIC_ACCESSincludesshare, which opens the links you mint and nothing else.
RSS & Calendar Feeds
The station publishes lightweight, public, read-only feeds so you can follow your yard from a feed reader or a calendar app without leaving a browser tab open.
| Feed | Path | Format |
|---|---|---|
| Rare / first-of-station | /feeds/rare.rss | RSS 2.0 |
| Rare / first-of-station | /feeds/rare.ics | iCalendar |
| Everything today | /feeds/today.rss | RSS 2.0 |
The rare feeds list first-ever-at-this-station detections with confidence above 0.85 (strictly > 0.85) — a low-noise stream of genuinely new birds. The today feed is every detection from the current day (chatty by design).
Discovery
The dashboard advertises the rare RSS feed in its <head>, so feed readers offered the page will find it automatically:
<link rel="alternate" type="application/rss+xml"
title="BirdNet · rare detections" href="/feeds/rare.rss">
Each feed item links back to the detection detail page for that bird, and the iCal events carry a stable UID, a 3-minute duration, and the same detail link.
BNB_BASE_URL — make the links absolute
Feed items need absolute URLs so they work in an external reader. Set BNB_BASE_URL to how the station is reached from outside:
BNB_BASE_URL=http://birdnet.local # mDNS on the LAN
# or
BNB_BASE_URL=https://birds.example.com # behind a reverse proxy
If unset it defaults to http://localhost:8502 (the server’s own port). A request can name its own base with ?base=…, which is how a reverse proxy gets the scheme and path prefix right, but only for an address the station already knows: the host the request was addressed to, or the host of BNB_PUBLIC_URL or BNB_BASE_URL. Any other ?base= is ignored and the default is used, so a feed address cannot be made to point at someone else’s site. If a proxy rewrites Host and your readers pass ?base=, set BNB_BASE_URL. Cap item counts with ?limit=….
Caching
RSS responds with Cache-Control: private, max-age=300 (5 minutes); iCal with private, max-age=3600 (one hour), since calendar clients repoll slowly. Both say private, so a shared cache or CDN in front of the station will not keep a copy. An empty station still returns valid, well-formed XML/iCalendar.
These endpoints are unauthenticated and expose the same information as the public dashboard. If you gate the UI behind a reverse proxy, decide whether to expose
/feeds/*alongside it.
Display Preferences
BirdNet-Behavior adapts to how and where you watch it — a phone on the trail, a wall-mounted Pi touchscreen, a desktop in a bright room. The Display Preferences card on the Station Health tab (/station) lets each viewer tune the interface; choices are stored locally in the browser, so they’re per-device and need no login.
What you can change
-
Theme — Light, Dark, or follow the operating system. Dark mode is a cool “observatory” black tuned so the moss/dawn/rare accents glow.
-
Density — Compact, Regular, or Comfy. Compact tightens spacing for dense dashboards and small screens; comfy opens it up for touch and across-the-room reading.
-
Motion — Reduced honours users who prefer fewer animations (and is picked up automatically from the OS “reduce motion” setting); the live-feed pulse and chart transitions are quietened.
-
Contrast — High strengthens borders and text contrast for bright environments and accessibility.
-
Clock and Temperature — Station (follow the owner’s default, below), or 24-hour / 12-hour and °C / °F for this browser. A 12-hour clock prints
4:31 PM; midnight is12:xx AM. Every time the pages show as text and every temperature goes through one formatter; the station stores and exports Celsius and 24-hour ISO times whatever is shown, and feeds, CSV, JSON and the API are not changed. Not converted: the hour labels, tooltips and readouts inside the charts, which are plain text in SVG and attributes, the admin tables whose times are labelled UTC, and the public share page, which is a document of its own. -
Listening — three aids for quiet clips and noisy sites, applied while a clip plays in this browser: Boost (up to +24 dB), Cut low rumble (a high-pass filter, 20 Hz to 8 kHz, logarithmic, for wind and traffic) and Even out loud and quiet parts (a compressor). They apply to every player on every page (the play buttons on Today, the Clips list, the live listen, a clip’s own page); the controls also sit in a Listening aids fold on a clip’s page. All three are off by default and then the clip is played exactly as the browser always did. Nothing is ever changed in the recording or in a download.
The chain ends in a soft limiter, so the output never passes full scale: measured through the page’s own graph on four real clips (BirdNET-Pi’s magpie, birdnet-go’s tawny owl and soundscape, and a fixture clip) at up to +24 dB with the compressor on, the output peak stayed at or below −0.2 dBFS in all 28 cases; without the limiter the same settings reached +16 dBFS. A large boost on an already loud clip is limited, not clean: use it for quiet recordings. The controls are keyboard-operable, announce their values to a screen reader, and use no animation. Needs a browser with Web Audio; without one they say so and change nothing.
The station’s default
Settings → General → System & Display → Clock and Temperature unit set what a browser that has not chosen shows (time_format 24h or 12h, temperature_unit celsius or fahrenheit; served at GET /api/v2/display). The page is first printed in the canonical 24-hour, Celsius text and re-printed by static/display-format.js; the default is remembered for the tab in sessionStorage, so a 12-hour station flashes 24-hour text only on the first page of a tab, and with scripting off the canonical text stands. A test (the_clock_and_temperature_are_printed_in_one_place) fails on a page that prints a time or temperature by hand, and tools/visual-qa/display-format.mjs holds the browser’s formatter to the same table of cases as the Rust one.
How it’s applied (no flash on load)
Preferences are stored under the theme, bnb-density, bnb-motion, and bnb-contrast keys in localStorage (the listening aids under bnb-listen; a browser that blocks storage simply does not remember them). A tiny guard script runs before first paint — inline in the main layout, where it sets the data-theme / data-motion / data-contrast attributes and the --density variable on <html> immediately, and as /static/theme-guard.js for the standalone admin pages, which applies only the theme and density keys. That pre-paint step is what prevents the brief “flash of the wrong theme” (FOUC) when you reload a page.
Because the values live in the browser, they persist across reloads and survive station restarts, and two people looking at the same station on different devices each get their own settings.
Kiosk Mode
Kiosk mode (/kiosk) is a full-screen, auto-refreshing display for a wall-mounted screen — a Raspberry Pi touchscreen by the window, a spare monitor in the hallway, a TV in the visitor center.
It strips away the navigation chrome and shows the numbers that read from across the room — today’s count, the all-time total, species, and a stream of the most recent detections — refreshing on its own so it never goes stale.
To leave kiosk mode, click the dimmed Exit link in the top-right corner, or press Esc — either returns you to the full dashboard. (The link stays faint so it never competes with the display, and brightens on hover.)
Tips for a dedicated display
- Point a full-screen browser at
http://<your-pi>:8502/kiosk. - Kiosk mode defaults to the dark “observatory” theme, which is easy on the eyes for an always-on screen and avoids burn-in on OLED panels.
- On Raspberry Pi OS, Chromium’s
--kioskflag plus the URL is all you need in an autostart entry.
For an always-on Pi display, make sure a system CJK font (e.g.
fonts-noto-cjk) is installed if you expect non-Latin species names — the dashboard itself ships all its Latin fonts self-hosted and never calls out to a font CDN.
Settings & Detection
The admin area lives under /admin. The Settings page (/admin/settings) persists everything you change into the SQLite settings table.
When a change takes effect
Every field on the page carries a small badge: Applies immediately (the part of the station that uses it reads the stored value again on its own, typically within about half a minute) or Applies after restart (the value is read once, when the station starts). The badge is drawn from one table in the code, and a test fails if a field on the page lacks one, so the page cannot disagree with itself.
When you have saved something that is still waiting for a restart, a banner at the top of the page lists which settings and offers the Restart button. Under a service manager (systemd, Docker’s restart policy) the button stops the process and the manager starts it again. A station started by hand restarts itself in place with the same arguments; if neither is possible the button says so instead of stopping the station and leaving it stopped.
The bird-identifying model
At the top of Settings → Capture (and as step 2 of the setup wizard), the Bird-identifying model panel says whether the classifier the station needs is installed. If it is missing, Install downloads it (checked against a pinned SHA-256, with a progress bar and a free-space check first) and the panel then offers the restart that loads it. This is the same installer as --install-model; the program, not the browser, does the download.
Numbers the page refuses
A number typed into a field is checked against the range the station actually accepts before it is saved, and the message names the range. The same bounds apply to PUT /api/v2/settings and, as warnings, to --doctor for the config file: frequency shift, sunrise/sunset recording offsets, notification cooldown, dead-man hours, clip retention days, files kept per species, disk purge threshold, extraction length, rare-species window, stream retention and size, SMTP port, email cooldown and clip loudness target. (They are warnings, not errors, in --doctor because an error makes the station fall back to its last-good configuration file.)
How a setting is resolved
Most settings can be supplied three ways — a CLI flag (or its BIRDNET_*
environment variable), a line in birdnet.conf, and this page. When more than
one is set, the station resolves them in this order:
- An explicit CLI flag or
BIRDNET_*variable — if you passed it, it wins. - This page — what you save here beats the config file.
birdnet.conf- The built-in default.
So a Docker station pinned with -e BIRDNET_SEGMENT_DURATION=30 keeps that value
no matter what the form says, while a bare-metal station that never passes the
flag is governed entirely by this page.
Settings not on this page
Two things are deliberately configured elsewhere in the browser:
- The admin password. It is stored as an Argon2id hash in the accounts
database, seeded from the
CADDY_PWDenvironment variable — never as a settings row. See Remote Access & Security. - Per-source audio properties (channels, sample rate, gain, RTSP transport). Each microphone or stream carries its own, on Audio & Microphones.
A handful of settings are file-only on purpose, each for a stated reason — secrets that must not live in the database a backup copies, listen address and TLS (a typo locks you out of the page you would fix it on), and similar. Where to change it lists every setting, the browser page that owns it, and for the exceptions why.
Detection
- Confidence threshold — the minimum score a detection must clear to be logged (default
0.70, BirdNET-Pi’s own; it was 0.75 up to v0.17.1). Raise it to cut false positives; lower it to catch faint calls. - Per-species thresholds — override the global threshold for individual species (useful for a noisy local mimic, or to be stricter about a rare bird).
- Sensitivity (0.5–1.5) — the BirdNET sensitivity parameter (also
SENSITIVITYinbirdnet.conf). It only affects BirdNET V2.4 models; the BirdNET+ V3.0 preview ignores it. It means what it means in BirdNET-Pi and BirdNET-Analyzer, so a value carried over from BirdNET-Pi reproduces that station’s confidence scores: the model’s raw score is passed through a sigmoid with slopeclamp(2 − value, 0.5, 1.5). A higher value therefore flattens the curve, and at any confidence threshold above 0.5 (all the usual ones) it gives fewer detections, not more. birdnet-go’ssensitivityis the slope itself, so to carry a birdnet-go value across, enter2 − value. - Human-sound privacy — a station near a house will sometimes hear speech, and two independent rules keep it out of the log and the saved clips: a window, and the windows either side of it, are suppressed when either fires. Each is a field in the Detection section.
- The rank rule, Privacy Rank (
PRIVACY_RANK, default10,0= off, up to12000), is BirdNET-Pi’s own: it fires when a human class (speech, whistling, other human sounds) is among the model’s N highest-scoring classes, read before the confidence threshold. It is on by default, including on a station upgraded from a release that had no such rule and that sets neither key;10is BirdNET-Pi’s behaviour. A larger number drops more windows. It is a whole number, a count of classes and not a confidence:0.02is refused rather than cut to0(which would switch the rule off), and so are10%,1,000, a negative number and anything above12000, with a message that names the range. Leave the field blank to fall back to the default. The same bounds apply to the config file (--doctorchecks them) and toPUT /api/v2/settings. A flag or variable other than10(--privacy-rank,BIRDNET_PRIVACY_RANK) still beats the page. - The confidence rule, Privacy Threshold (
PRIVACY_THRESHOLD, default0= off, typically 0.01–0.03), fires when the model’s confidence for a human class reaches the value. It shares its name with a BirdNET-Pi key that is on a different scale, so a BirdNET-Pi value does not carry across; Same audio, same detections has the conversion.
- The rank rule, Privacy Rank (
- Species-frequency filter — uses your location and the week of the year to down-weight birds that shouldn’t be present, with a configurable
SF_THRESH. To see what it admits, and why a particular bird is held back, open Why isn’t a bird heard here? on the Species page. - Noise filter (
NOISE_THRESHOLD,NOISE_CLASSES) — optionally discards a window in which a watched non-bird class (by defaultDog) scores at or above the threshold. Off by default; see Tuning §7. There is no filter that drops rainy or windy audio before it reaches the model. - Analysis overlap (0–2.9 s) — how far consecutive analysis windows (3 s for V2.4, 4.5 s for the V3.0 preview) overlap. Higher catches calls that straddle a window boundary, and costs CPU in proportion:
2.0triples the inference work per recording. - Repeat confirmation — record a species only when enough of the windows within six seconds heard the same thing. A real bird sings across several windows; a car door or a squeak of the mount usually fires in exactly one. It needs the overlap above — with none, there is only one other window to agree with, and each option in the dropdown prints the overlap it needs. Off by default. See Tuning.
Species & quarantine
Rare-bird quarantine rules decide which detections are held for manual review instead of being logged automatically — see the review queue. You can also maintain per-species allow/exclude lists and image overrides here.
Allow and exclude lists
The lists under Settings → Capture → Species (/station/capture#species; the old /admin/species redirects there) decide which birds the station records at all:
- Exclude — these species are never recorded. Nothing is written to the database, no notification is sent, and nothing is uploaded to BirdWeather. Use it for a persistent local false positive, or for a species you would rather not log.
- Allow — when non-empty, only these species are recorded.
- Always allowed (whitelist) — species the species-occurrence filter would
hold back as unlikely for your location and week are recorded anyway. This is
BirdNET-Pi’s
whitelist_species_list.txt, with its meaning: it exempts a species from the occurrence threshold and from nothing else. A species on the exclude list stays excluded, one missing from a non-empty allow list stays out, and it does nothing on a station with no location set (nothing is held back there). It is for the one or two birds you know visit; lowering the occurrence threshold under Detection is the broader tool.
Enter either the common name or the scientific name; both work, and case and
surrounding spaces don’t matter. Changes take effect within about half a minute
— no restart. /admin/species/test previews the decision for every species your
station has seen, using the same code the detection path runs, so what it shows
is what will happen.
An allow list whose entries match no species this model knows is ignored, and a warning is logged, rather than being taken literally and suppressing everything.
Species storage
Settings → Capture → Species → Species storage (/admin/species/manage) is one row per species this station has ever recorded, with its detection count, how many still have audio on disk, how many you have locked, and when it was last heard. It exists for one recurring situation: a squeaky gate has produced four thousand Eurasian Wrens and you want them gone.
Three actions per row, each confirmed:
| Action | What it does | What survives |
|---|---|---|
| Exclude | Stops the species being recorded from now on | Everything already stored |
| Delete detections | Removes its rows | Nothing — this cannot be undone |
| Delete clips | Reclaims the audio files | The detections, so the record of what was heard stays intact |
Locked detections are never touched by any of them. The row shows the locked count before you press anything, and the result tells you how many were kept — so a species you deleted that still has two detections is explained rather than looking like a bug. Unlock those first if you meant to include them.
Measure adds up the species’ clips on disk. It is a separate button rather than a column because it has to stat every file, and doing that for every species on every page load would make the page take many seconds to open on an SD card.
All three actions are written to the audit log.
Species photographs
Each species page shows a photograph, cached on disk so the station works offline after the first fetch. Three sources are available.
Wikipedia (the default) needs no key and covers most species. For a long tail it has no photograph at all, and for many others it has a museum skin, an egg, or a range map rather than a living bird.
AviCommons (IMAGE_PROVIDER=avicommons) needs no key. AviCommons is a library of cropped, oriented Creative Commons bird photographs made to be embedded. It is tried first and Wikipedia fills in behind it. The station downloads AviCommons’ one JSON index (1.6 MB, which its README asks to be fetched once and not on each page load) and keeps it in the image cache folder for 30 days, then each photograph once. Only the CC0, CC BY and CC BY-SA images are used, the same rule as Flickr below: of the index’s 10,185 images (checked 2026-10-21) 2,620 qualify, the rest are NonCommercial. Against the 6,522 species of the BirdNET V2.4 labels that is 1,628 species (25 %) with an AviCommons photograph, and the rest keep Wikipedia’s. Each photograph is shown with its photographer, its licence and a link to AviCommons. The project’s repository declares no licence of its own; the terms are per image, in the index.
Flickr searches Flickr first and falls back to Wikipedia when Flickr has nothing, so turning it on can only add coverage — it never leaves a species blank that Wikipedia could have filled. It needs a free API key from Flickr’s app page:
IMAGE_PROVIDER=flickr
FLICKR_API_KEY=your-key-here
Showing your own photographs
Set FLICKR_FILTER_EMAIL to the email address on your Flickr account and the
search is restricted to your own photostream — your pictures of the birds your
own station heard. Wikipedia still fills in the species you have not
photographed, which is the whole point of the fallback: a filtered stream has a
few dozen species in it, not nine thousand.
IMAGE_PROVIDER=flickr
FLICKR_API_KEY=your-key-here
FLICKR_FILTER_EMAIL=you@example.com
Licensing
Only the Creative Commons licences that permit commercial use are requested — CC BY, CC BY-SA, CC BY-ND, CC0, Public Domain Mark, Flickr Commons, and US Government works. Your station’s page may end up published; the station cannot know whether that counts as commercial, so it asks only for photographs where the answer does not matter.
Attribution is not optional under CC BY and CC BY-SA, so every photograph is shown with its photographer’s name and a link to the Flickr page carrying the licence terms. A photograph Flickr returns with no photographer named is skipped rather than shown uncredited.
Notes
- Switching provider does not re-download anything: the cache is keyed on the species name alone, so images already on disk are kept.
- A wrong or revoked API key is reported in the log and the station falls back to Wikipedia rather than showing nothing.
FLICKR_API_KEYis masked in support bundles, and so is the local part ofFLICKR_FILTER_EMAIL(you@example.combecomes***@example.com) — the domain stays because it is the diagnostic half.- Neither source is used at all in offline mode, or when the image cache is
disabled with an empty
IMAGE_CACHE_DIR.
Spectrograms
Two settings under System & Display change how every spectrogram is drawn, and a change shows on the next image, with no restart (the answer is revalidated rather than cached for an hour, so a browser that already has the old picture asks again):
- Spectrogram colours — Viridis (blue to yellow, what earlier versions drew and the default), Magma and Inferno (black to pale or bright yellow), Grey (quiet is black) and Grey, inverted (quiet is white, the look of a printed figure). Louder is always lighter, so the order survives a grey print; a unit test holds CIELAB lightness monotonic for every map. Magma and Inferno are matplotlib’s tables (CC0).
- Spectrogram dynamic range — 60, 80 (the default), 100 or 120 dB. Cells more than this far below the loudest in the clip are drawn as the floor. A smaller range gives more contrast and lets faint calls sink into the background. birdnet-go offers 80, 100 and 120 (
dynamicrange); 60 is added here.
There is no gain setting, because the image is stretched between its own quietest and loudest cell, so a gain would change nothing. The live spectrogram on Recordings is drawn by the browser and keeps its own colours.
Your picture on the Today page
BirdNET-Pi’s CUSTOM_IMAGE and CUSTOM_IMAGE_TITLE. Set Today page picture (file) to the full path of a PNG, JPEG, GIF or WebP file and Picture title to its heading, and the Today page shows it between the day strip and the log. A webcam works the same way: something on the station keeps the file current (for example a cron job running ffmpeg -i rtsp://camera/stream -frames:v 1 -y /home/pi/camera/latest.jpg every minute) and the page checks it every ten seconds while the tab is visible, fetching it again only when the file’s size or time changed. BirdNET-Pi re-reads it every second and inlines it; this does one conditional request in ten seconds, which is easier on a Raspberry Pi.
The file is held to what a picture is: a regular file, at most 8 MiB, a PNG, JPEG, GIF or WebP by its first bytes whatever its name says (so a path typed with a slip, or /etc/passwd, shows nothing; SVG is refused because it can carry script). If the file cannot be read the block says so and keeps trying. It is a block like the others: hide or move it under Today Page Layout. Only a file path is supported, as upstream; a camera that only offers a URL needs the snapshot saved to a file first.
This is not custom_image_dir, which replaces one species’ photograph.
Other categories
The Settings sidebar also covers Location, Audio (see Audio & Microphones), Notifications & Email (see Notifications & Integrations), MQTT / Home Assistant, and System.
Data quality
The Quality dashboard (Settings → Data, /station/data#quality; the old /admin/quality redirects there) summarizes the health of your detection database — the confidence distribution, a 30-day confidence trend, an hourly quality profile, and a ranked list of low-confidence species that are good candidates for a stricter per-species threshold.
Species Names in Your Language
BirdNet-Behavior can show bird names in 35 languages besides English. Choose one
under Settings → General → System & Display → Common-name language, or set
DATABASE_LANG=de in birdnet.conf (--lang de, or BIRDNET_LANG, work too).
It takes effect when the station next starts. A --lang given on the command
line wins over the Settings page and birdnet.conf, as every flag does, so the
page will show the stored choice while the station uses the flag’s.
The name of a language is its code: af, ar, ca, cs, da, de, en, es,
et, fi, fr, hr, hu, id, is, it, ja, ko, lt, lv, nl, no,
pl, pt, ro, ru, sk, sl, sr, sv, th, tr, uk, vi, zh_CN and
zh_TW. A regional spelling (de_AT, pt-BR) and any capitalisation work. Chinese
has no plain zh: its two scripts are two sets of names, and the station will not
guess which you meant.
Only what is shown changes
A detection is stored under the name the classifier gave it, in English, and that stays true whatever language you choose. The language decides what is drawn. That is different from BirdNET-Pi, which stores the translated name, and it is deliberate:
- Changing language loses nothing and splits nothing. Last spring’s Eurasian Blackbird and this spring’s Amsel are the same bird in every list, count and chart, and you can switch back tomorrow.
- Your exclusions keep working. A species list that says “Dog” still matches the dog after you switch to German, because the list is matched against the classifier’s names.
- Links, forms, the JSON API, the exports, MQTT and webhooks carry the stored
name. A program that matches on
Eurasian Blackbirdkeeps working; a link that saidname=Amselwould open a page that has never heard of the bird.
Everything a person reads is translated: the Today feed and its top-species card, the species list, photos and life list, a species’ page, a detection’s page, the recordings and search results, the history and reports, the review queue and quarantine, the dashboard, the kiosk, the share page, the Patterns charts, the RSS and calendar feeds, notifications and the weekly report, and the live feed.
How much of your classifier it covers
The names come from BirdNET-Pi’s translation tables, which cover 7,058 species. The BirdNET V2.4 classifier reports 6,522 and the BirdNET+ V3.0 preview 11,560, and a language does not have a name for every one of them: Swedish names nearly all the species in the tables, Korean fewer than one in ten. A species with no name in your language keeps the classifier’s own, so a station set to Korean shows mostly English.
The language list on the Settings page shows, for your classifier, the share of
its species each language names, and birdnet-behavior --doctor prints the same
count for the language you have chosen. GET /api/v2/languages returns both.
These names are upstream’s, not reviewed here; a wrong one is upstream’s error.
If you want a name different from the one shown, or a language that is not in the
list, put a file named de_labels.txt (or fr_labels.txt, and so on) in a
directory and point --labels-dir (LABELS_DIR) at it. Each line is
Scientific name_Common name, and its names replace the built-in ones for that
language.
Searching and typing names
You can type a name in your language into the species search, the command palette (⌘K), the companion-species lookup, and the Today, Recordings and Search boxes. You can also add one to an exclusion or allow list: it is saved as the scientific name, which means the same thing in every language and survives a change of language.
Three things still go by the classifier’s name, because it is the stored name:
- Sort order. “Species A–Z” sorts the classifier’s names, so a German list will not be alphabetical in German.
- An alert rule’s webhook body.
{{species}}is the stored name, and{{sci_name}}is the scientific name in every language. (A rule’s pattern matches either:Elster*andEurasian*both select the magpie on a German station. A notification’s$comnameis drawn in your language; its links carry the stored name.) - Entries typed before this existed. A name typed into a species list used to be saved as typed. A German name saved that way matches nothing, because the classifier never reports it; remove it and add it again, and the scientific name is saved instead.
Fonts
The interface’s own typefaces cover Latin and Latin Extended. A name in another
script (Japanese, Korean, Chinese, Thai, Arabic, Cyrillic) is drawn by the
browser’s own fonts, which every phone and desktop has. A bare Raspberry Pi
kiosk may not: if the wall display shows empty boxes where a name should be,
install a font that covers the script (fonts-noto-cjk for Chinese, Japanese and
Korean).
Where the names come from
crates/birdnet-core/data/l18n/README.md
says whose work they are, under what licence, how many names each language has,
and how to rebuild the table. The short version: BirdNET-Pi’s model/l18n/
files at a pinned commit, reduced to the names that are genuine translations,
credited to @patlevin and Wikipedia by BirdNET-Pi itself.
Running More Than One Classifier
Your station always runs one classifier — BirdNET, the thing that makes it a BirdNET station. This page is about running a second one beside it, and what the station does with two opinions about the same three seconds of audio.
You do not need this. One classifier is the supported, tested, default configuration, and most stations should stay there. Read on if you are in the tropics, where Google Perch v2 is materially better than BirdNET, or if you want a second opinion recorded on every detection. The first section is about the one classifier every station has: which one a new station gets, and what changing it involves.
Choosing and changing the primary classifier
A new install runs BirdNET V2.4 with its range (occurrence) filter v2, the network BirdNET-Pi and birdnet-go run; BirdNET+ V3.0 preview (a different network, with its own geomodel as the occurrence filter) is an explicit choice. The two are never mixed: the V2.4 range filter is indexed like the V2.4 labels and means nothing to another network, and the geomodel is matched by name through its own label file.
| Where | Choose V3.0 on a new station | What decides an existing station |
|---|---|---|
| Bare metal | install.sh --classifier birdnet-v3, or BIRDNET_CLASSIFIER=birdnet-v3 | the classifier /etc/birdnet/birdnet.conf names; a --classifier that disagrees is reported as not applied |
| Docker | BIRDNET_CLASSIFIER=birdnet-v3 in .env | the V3.0 model file already on the volume (an explicit BIRDNET_CLASSIFIER overrides it) |
| macOS | the same installer flag | the same rule as bare metal |
On bare metal the rule, first match: an existing config keeps the classifier it names (only a config whose MODEL_PATH is the V2.4 file the installer manages counts as V2.4; every other existing config is treated as V3.0, because that is what every earlier install fetched); otherwise --classifier, then BIRDNET_CLASSIFIER; otherwise, if the V3.0 model file is already in the model folder (a station restored from a backup of its data without its config), V3.0; otherwise V2.4. One Classifier: line in the install output says which was chosen and why. Installation and Running with Docker have the details and say where the files come from.
Changing a running station is deliberate, never automatic. No install, update, repair or reinstall does it, because the history would then hold rows two different networks scored. Rows already stored keep their old scores and names, and a species the two label files name differently appears under both names. If you do want to change:
-
Install the other classifier’s files (see Installing one below).
-
Point the station at them in its configuration, then restart and run
birdnet-behavior --doctor. Moving to V2.4:MODEL_PATH=<model dir>/birdnet-v24.onnx LABELS_PATH=<model dir>/birdnet-v24_labels.txt METADATA_MODEL_PATH=<model dir>/birdnet-v24-range-v2.onnx # no METADATA_LABELS_PATH: the range filter needs none, so remove a geomodel's label file from herewith
CONFIDENCE,SENSITIVITYandPRIVACY_RANKas Same audio, same detections describes. In Docker, setBIRDNET_CLASSIFIER=birdnet-v24instead and recreate the container. -
Moving the other way (V2.4 to V3.0) has no one-line recipe here: the V3.0 files and their pins are named in
installer/lib/10-config.sh, andinstall.sh --classifier birdnet-v3is only for a new station. In Docker,BIRDNET_CLASSIFIER=birdnet-v3is enough.
These steps were read from the installer and the container entrypoint and their tests, not run end to end on a station.
What a second classifier buys, and what it costs
Every classifier routed to a recording runs on every chunk of it. Two classifiers mean two inferences per chunk, which on a Raspberry Pi is the expensive part of the whole pipeline. In exchange:
- A species only one of the two models knows is now heard. The results are a union, not an intersection.
- Each detection records which classifier produced it, and how many classifiers reported that species in that chunk.
- Nothing is lost. A model that reports a species alone still produces a detection.
The cost is real and worth measuring before you commit to it: check the daemon’s CPU use for a day with one classifier, then compare.
Installing one
The station carries a small catalogue of classifiers it knows how to fetch and verify. The default one (BirdNET V2.4 and its local species filter) can be installed from the browser: Settings → Capture → Bird-identifying model → Install, or step 2 of the setup wizard. The others below are installed from the command line. To see the catalogue:
birdnet-behavior --install-model list
To install one:
# Docker: BIRDNET_MODEL_DIR is already /data/model in the container
docker compose exec birdnet birdnet-behavior --install-model perch-v2
# Bare metal: the installer's model folder is <data dir>/models, but --install-model
# writes to /data/model unless BIRDNET_MODEL_DIR (or MODEL_DIR in birdnet.conf) says
# otherwise. Name the folder, and run as the service user, who owns it:
SVC=$(systemctl show -p User --value birdnet-behavior)
sudo -u "$SVC" env BIRDNET_MODEL_DIR="$(getent passwd "$SVC" | cut -d: -f6)/BirdNet-Behavior/models" \
birdnet-behavior --install-model perch-v2
There is no birdnet user on a bare-metal station: the installer runs the service as the account that ran sudo, and systemctl show -p User --value birdnet-behavior prints it.
What it can install:
| id | what | it replaces or joins |
|---|---|---|
birdnet-v3 | BirdNET+ V3.0 preview, 32 kHz, 4.5 s windows: an explicit choice for a new station (it was the default up to v0.17.1) | the primary; it cannot run beside V2.4, which wants 48 kHz |
birdnet-v24 | BirdNET V2.4, the network BirdNET-Pi and birdnet-go run (48 kHz, 3 s windows), with the label file written by the binary itself (below). The default for a new station | replaces the primary: it cannot run beside V3.0, because a recording is resampled once and the two want different rates |
birdnet-v24-range-v2 | the V2.4 species-occurrence filter, v2 (BirdNET-Pi’s default is v1, which is not hosted as ONNX and so is not in the catalogue) | METADATA_MODEL_PATH, with V2.4 only |
perch-v2 | Google Perch v2 | runs beside the primary |
The installers do this for you on a new station (V2.4 and its range filter). To make an older station report what BirdNET-Pi would, install birdnet-v24 and birdnet-v24-range-v2 and follow Same audio, same detections.
This runs in the foreground and exits — deliberately. It is a several-hundred-megabyte download that takes hours on the uplink a field station has, and you should be able to watch it, interrupt it, and see it fail. There is no button in the web interface for the same reason: a multi-hour download inside a request handler has nowhere to report progress, and a retry would start a second download beside the first.
Three things it does before and while it downloads:
- Checks free space first, with a 512 MiB margin on top of the file. Filling the card stops the station recording, which is worse than not having the classifier. A filesystem that will not say how much space it has counts as no space rather than plenty.
- Verifies as the bytes arrive. The sha256 of each file is compiled into the binary — not fetched alongside it — so the checksum is a promise this project makes rather than one the download site makes. The digest is computed while streaming, so a 541 MB model never has to fit in a 1 GB board’s memory at once.
- Never leaves a half-file the station would load. Unverified bytes carry a
.partname; the file is renamed into place only after its digest matches.
Running it again is cheap: a file already in place whose sha256 matches is not fetched again (the command says so, and skips the free-space check for it), while a truncated or substituted file under the right name is fetched afresh. A download that is interrupted starts over; it does not resume.
Files land in the folder named by BIRDNET_MODEL_DIR, else MODEL_DIR in the config file, else /data/model. The same catalogue is readable over the API at GET /api/v2/models/catalog.
Where the files come from. birdnet-v3 and the Perch model are fetched from the URLs in the catalogue; birdnet-v24 is the model from birdnet-go’s published ONNX build on Hugging Face at a pinned revision, and birdnet-v24-range-v2 the range filter from the tphakala/birdnet-go repository at a pinned commit. The V2.4 labels are not downloaded: the binary writes birdnet-v24_labels.txt itself, verified against a digest, with BirdNET-Pi’s English names on V2.4’s class order. birdnet-go’s own label file, which the model is published with, names 460 of the 6 522 classes differently from BirdNET-Pi (Pica pica is “Eurasian Magpie” in one and “Common Magpie” in the other), and a station that imported a BirdNET-Pi history would otherwise hold one species under two names. A station that already has the older label file keeps it, because the installers skip a file that is present; run birdnet-behavior --install-model birdnet-v24 yourself to replace it (the labels are always rewritten from the binary, the model that already verifies is not fetched again), then restart. The three files a V2.4 station has are birdnet-v24.onnx, birdnet-v24_labels.txt and birdnet-v24-range-v2.onnx.
Perch v2 ships without a labels file. You must supply one with 14 795 rows. Labels are positional against a model’s output, so the wrong file reports species under other species’ names at full confidence — a failure with no symptom at all.
Configuring it
Up to three classifiers total. The primary is always loaded and is whatever BIRDNET_MODEL / BIRDNET_LABELS already point at.
# Name the primary, if you want it called something other than `birdnet`
# in routes and on detections.
MODEL_ID=birdnet
MODEL_THRESHOLD=
MODEL_SAMPLE_RATE=
# The second classifier. Each needs its OWN labels file. (<model dir> is
# /data/model in Docker and <data dir>/models on bare metal.)
MODEL_2_PATH=<model dir>/perch-v2.onnx
MODEL_2_LABELS=<model dir>/perch_labels.csv
MODEL_2_ID=perch
MODEL_2_THRESHOLD=0.35
MODEL_2_SAMPLE_RATE=32000
# A third, if you have one.
MODEL_3_PATH=
MODEL_3_LABELS=
MODEL_3_ID=
MODEL_3_THRESHOLD=
MODEL_3_SAMPLE_RATE=
Every key also exists as BIRDNET_MODEL_2_PATH and so on in the environment; see .env.example.
MODEL_n_SAMPLE_RATE is not optional for Perch. Its tensor declares [-1, 160000], which is 5 s at 32 kHz and equally 3⅓ s at 48 kHz — nothing in the shape distinguishes them, so it has to be told. BirdNET’s own shapes are unambiguous, and the station derives those for itself.
MODEL_n_THRESHOLD (and MODEL_THRESHOLD for the primary) holds that classifier’s detections to its own confidence bar, whether it is below the station’s or above it. Leave it unset to use the station’s. A per-species threshold still overrides either.
Routing sources to classifiers
A second classifier runs only where a route sends it: every audio source is judged by the primary alone unless MODEL_ROUTES names it. That is deliberate — adding a classifier should not quietly double the inference cost of every microphone on a board that was keeping up. To send particular microphones to particular classifiers:
MODEL_ROUTES=pond:perch,garden:birdnet+perch
The source is its audio_sources row id — the same label GET /api/v2/system/capture lists, and the one each source’s recordings carry in their file names, which is how the detection loop knows where a recording came from. To run both classifiers on your only microphone, name that microphone’s id: MODEL_ROUTES=<id>:birdnet+perch. That needs the microphone to be listed on the Audio page, which is what gives it an id; a single microphone set only in birdnet.conf records without one, and gets the primary alone. Use + for more than one classifier on one source, and commas between sources.
Two rules here exist because a station runs unattended for months:
- A source you do not name gets the primary classifier, never nothing. Silence must not be reachable by forgetting to write a line.
- A route naming a classifier you have not configured stops the station at startup, where the journal and
--doctorwill show it. Acceptingpond:pecrhand discovering at runtime that the pond routes to nothing would cost months of recordings from that microphone, and the station would look identical to one having a quiet season.
What the station does with two opinions
Each classifier is judged against its own threshold, and the results are combined per chunk:
- Union by species. Anything any classifier reported above its threshold becomes a detection.
- The highest confidence wins, and the detection records which classifier gave it (
model_id). model_agreementcounts how many distinct classifiers reported that species in that chunk.
Agreement is never folded into the confidence. An averaged or bonused number would have no calibration behind it while sitting in the same column as a real one, quietly changing the meaning of every threshold you have set and every historical comparison you make. The confidence you see is the winning classifier’s own output, unchanged.
model_agreement is NULL on rows written before this feature existed — a third state, and not the same as 1 (“one classifier asked, one answered”).
Both columns are stored on every detection today, and nothing in the web interface or the API reads them back yet. They are there so that a station running two classifiers now is not analysing a season of data later with the corroboration missing.
Windows: what “the same chunk” means
Classifiers do not agree on how much audio a judgement covers. BirdNET+ V3.0 wants 4.5 s; Perch v2 wants 5.0 s.
The station cuts each chunk to the longest window any loaded classifier wants, and advances by the shortest. Each classifier reads its own window from the start of the shared chunk.
The reason it is not simply “cut and step by the longest” is a gap you would never see. Cut at 5.0 s and step 5.0 s, and BirdNET hears the first 4.5 s of every chunk and never the last half-second — audio no classifier is ever given, recurring on every chunk for as long as the station runs, producing no error and no missing-data indicator. The detections that would have been there simply are not there.
Stepping by the shortest window removes it. The shortest-window classifier gets exactly the chunks it would have had running alone, and longer-window classifiers get overlapping chunks instead of gaps.
What this costs, so you can decide: a classifier whose window is longer than the shortest runs longest ÷ shortest more inferences — 5.0 ÷ 4.5 ≈ 1.11, about 11 % more for Perch beside BirdNET. The daemon logs it at startup as extra_inference_ratio alongside both window lengths, so you do not have to work it out:
classifiers want different windows: chunking to the longest and stepping by the
shortest ... longest_window_secs=5.0 shortest_window_secs=4.5
extra_inference_ratio=1.1111112
One consequence worth knowing when you read detection times: a detection carries the chunk’s span, not its own classifier’s. A shorter-window classifier’s detection can therefore show an end time up to half a second later than the audio it actually read. The recorded span always contains that audio, so a clip cut from it contains the detection. With one classifier the two are identical.
What the station refuses, and why
A different sample rate. Every classifier must want audio at the same rate. A recording is decoded and resampled once; resampling it twice would double the most expensive step in the pipeline on the boards this runs on, and there is no rate that is correct for both. The station says so at startup and names both rates rather than starting and feeding one of them audio prepared for the other. Differing window lengths are fine — that is what the chunking above is for.
More classifiers than the machine can hold. All classifiers together may use at most half the machine’s effective memory ceiling — the cgroup limit if there is one, otherwise physical memory — counting each model’s file size plus a 256 MiB working-set allowance. On a 1 GB board that budget is 512 MiB, which one BirdNET model already fills, so a second is skipped and the arithmetic goes in the journal. A machine that will not report its memory gets one classifier, because “unknown” must not be read as “plenty”.
Nothing at all. A station with no classifier is not a degraded station; it is a station that has stopped being what it is for. Startup refuses it.
Not yet supported
Bat classification. BattyBirdNET is not a peer classifier — it is a chained second stage that consumes BirdNET v2.4 embeddings and needs 256 kHz audio fed through without resampling. That needs a capture path, a deliberate no-resample route, BirdNET v2.4’s 1024-wide embedding output (the birdnet-v24 classifier here emits species scores, not embeddings), and the ultrasonic validation filter, none of which ship yet.
See also
- Settings & Detection — thresholds, sensitivity and the rest of the detection knobs
- Tuning Detection Accuracy — what to change before reaching for a second model
- Audio & Microphones — the sources
MODEL_ROUTESnames
Audio & Microphones
Microphone setup is the single most common support topic, so it gets its own tab (Settings → Capture, /station/capture; the old /admin/audio redirects there) designed to be bulletproof.
Your sources at a glance
Every input the station listens on appears in the source list — USB sound cards and RTSP cameras alike. Each row shows a live level meter with SNR, uptime, the last detection, and a 24-hour count, so you can tell at a glance whether a mic is healthy.
You can run several sources at once — one or more USB/ALSA mics, a PipeWire source, and any number of RTSP streams, in any combination (this is the headline gap in the original BirdNET-Pi). Each source captures into its own subprocess, tags its recordings with its own stream label, and gets its own health gauge, so detections stay attributable to the source that heard them.
Independent supervision (built for the field)
Every source is supervised on its own, which is what makes a multi-camera station survivable when nobody is on site:
- One source failing never disturbs the others. A dead capture process — a camera that rebooted, a mic that was unplugged, a network blip — is restarted on its own with capped exponential backoff (2 s → 4 s → … → 60 s, then every 60 s forever; a source down for an hour is still recording when it comes back on hour two). The other sources keep recording and detection never pauses.
- Silent stalls are caught too. A source whose process is still alive but has stopped delivering audio — a wedged RTSP session, a mic hung after a USB re-enumeration — is detected by watching its segment output: no fresh recording for several segment-durations and it is restarted exactly like a crash. A plain “is the process running?” check can’t see this; it’s the failure mode that quietly loses a whole night otherwise. (It fails open while the system clock is unsynced, so a wrong boot-time clock never triggers a false restart.)
- A source that keeps dying and coming back is caught as well. A marginal USB connection or an under-powered hub gives a source that dies every few minutes and restarts in seconds: it is live at every glance, its backoff never grows, and its uptime strip stays green, but every restart loses the audio around it. Restarts are counted over the last hour, and five or more make the source flapping: a line on its Station Health card, an issue in the banner, a warning in the log, and the
flappingstation-health condition for the notifier. - You can see it. The per-source
birdnet_audio_source_up{source="…"}Prometheus gauge reflects real liveness, and a source that has been down a couple of minutes logs a loud, rate-limited warning to the journal. - You can say which source Listen plays. Each row carries Make listen default; the source you pick is what
/streamserves — and so what the Listen button plays — when the listener has not chosen one of their own. A two-microphone station (feeder and nest box) has one that people actually want to hear, and this is where it says so. If that source is later disabled or removed, Listen falls back to the first working source rather than going silent. - You can restart one source by hand. Each row carries a Restart button that stops and starts that source alone, within a couple of seconds; every other source keeps recording, and nothing in flight elsewhere is lost. It is the right remedy for one camera that has wedged in a way the supervisor has not yet called stalled. Restarts you ask for are deliberately not counted towards the flapping verdict — that number is there to spot a source failing on its own. The same action is
POST /api/v2/control/restart-sourcefor automation, andGET /api/v2/system/capturereads the state back; both are in the API reference. Note that a per-source restart re-launches the source with the settings the service started with — it does not pick up an edit, which still needs a service restart.
Tuning the watchdog (expert)
The supervisor’s own timings are settable, and the defaults are what the station
has always used — leave them alone unless a specific site misbehaves. The knobs
are BIRDNET_WATCHDOG_CHECK_SECS (how often every source is reconciled),
…_STALL_SEGMENTS and …_STALL_FLOOR_SECS (how much missing output makes a
live process stalled), …_BACKOFF_BASE_SECS and …_BACKOFF_CAP_SECS (the
restart delay and its ceiling), and …_DOWN_WARN_AFTER_SECS and
…_DOWN_WARN_EVERY_SECS (how long down before the loud journal warning, and how
often to repeat it). Each is documented with its range in .env.example; a
value outside it is clamped and the adjustment is logged, so a station never
runs on timings its config file does not describe.
Two things that are deliberately not knobs:
- There is no maximum retry count. A field sensor unreachable for six hours must still be reachable on hour seven, and a supervisor that has given up is a station that is silently not recording. Upstream BirdNET-Go has one; this is a considered divergence, not an omission.
- None of these is “how long may the station go without a detection”. That
is
BIRDNET_DEADMAN_HOURS. A microphone writes segments through hours of silence, so no amount of quiet trips the stall threshold — the threshold is about segments arriving, not about birds calling.
Tuning a source
Open a source’s edit panel to change:
- Label and device,
- Quiet window — an optional per-source pause in the station’s local time, each end a clock time (
22:00) or a solar anchor (sunset,sunset+30,sunrise-15), e.g. to silence a noisy road-facing mic during rush hour without touching the others. (Earlier releases evaluated it in UTC; if you set the hours to compensate for that, set them back to the local hours you actually want. The source row shows the window with alocalsuffix so you can tell which convention a station is on.) - Pipeline toggles — high-pass filter, DC-offset removal, auto-gain control, and (network streams only) a 10-second read timeout that ends a stream which stops sending, so it is restarted.
- Equaliser — a filter chain of your own, when the toggles are not the right shape. See below.
- Camera stream (RTSP sources only) — the transport (auto / TCP / UDP — auto is TCP, the NAT-robust choice; force UDP only for a camera that needs it) and the tracks requested (see Audio-only cameras).
Sample rate (48 or 44.1 kHz) is chosen when the source is added. Per-source gain, the channel pick (mono / left / right / stereo — see Stereo microphones below) and bit depth are stored with the source and shown in its detail line, but are not yet editable from the UI; capture honours whatever the audio_sources row holds.
Per-source settings (device, gain, sample rate, transport, quiet window) are read when the capture subsystem starts, so restart the service after changing them for the change to take effect.
Aim for peaks near −6 dB. Gain set too high clips the loudest calls and hurts identification more than a quiet signal does.
The equaliser
The high-pass and DC offset toggles are two fixed filters: a corner at 120 Hz and one at 5 Hz. That is a reasonable compromise for a garden and the wrong answer at plenty of sites. A station beside a motorway wants a steeper cut than one filter section gives. A station with mains hum wants a notch at 50 or 60 Hz — a high-pass cannot remove hum without also removing everything below it, including the low end of a grouse, a bittern or an owl.
The Equaliser field takes a chain of filter stages. Type one and the response curve under the box redraws as you type, computed from the same coefficients that will filter your audio — so the picture cannot disagree with what you will hear.
Writing a chain
One stage per ;. Each stage is:
kind : frequency [ : q [ : gain [ : passes ] ] ]
| Kind | What it does | Uses gain |
|---|---|---|
highpass | Passes above the corner, cuts below | no |
lowpass | Passes below the corner, cuts above | no |
bandpass | Keeps a band, cuts either side | no |
notch | Cuts a narrow band, leaves the rest | no |
peaking | A bell: boost or cut around a centre | yes |
lowshelf | Boost or cut everything below a corner | yes |
highshelf | Boost or cut everything above a corner | yes |
- frequency is in hertz, and must be below half your source’s sample rate. A 48 kHz source can filter up to 24 kHz; a 16 kHz source only to 8 kHz. The form refuses a chain the source cannot carry rather than accepting it and silently ignoring it later.
- q is the width.
0.707(the default) is the gentlest useful shape. Higher is narrower:20is a hum notch,1is a broad tone control. - gain is in decibels, positive to boost, negative to cut. Ignored by the kinds that do not use it.
- passes repeats the stage, doubling its slope each time.
highpass:120:0.707:0:2is twice as steep ashighpass:120.
Worked examples
| Chain | For |
|---|---|
highpass:120 | The default high-pass, written out |
highpass:120:0.707:0:2 | Twice as steep — a windy or roadside site |
notch:50:20 | Mains hum, Europe/Asia (use notch:60:20 in North America) |
notch:50:20; notch:100:20 | Hum and its first harmonic |
highpass:200; lowshelf:400:0.7:-6 | Heavy traffic rumble |
peaking:4000:1:4 | Lift the band where most songbirds sit |
What a chain replaces
A non-empty chain replaces the high-pass and DC-offset toggles. It does not stack on top of them, so a chain with your own 120 Hz corner gives you one filter, not two. Automatic gain control is unaffected either way: it is a dynamic-range process, not a filter.
Clear the field to go back to the toggles.
One thing worth knowing about the toggles
The two backends do not implement the high-pass toggle identically. A local microphone gets a one-pole filter (6 dB/octave); an RTSP camera gets ffmpeg’s two-pole one (12 dB/octave). From the same tick-box, a microphone therefore keeps considerably more low-frequency energy:
| Source | 20 Hz | 30 Hz | 50 Hz | 60 Hz | 80 Hz | 120 Hz |
|---|---|---|---|---|---|---|
| Microphone | −15.7 dB | −12.3 dB | −8.3 dB | −7.0 dB | −5.1 dB | −3.0 dB |
| RTSP camera | −31.1 dB | −24.1 dB | −15.3 dB | −12.3 dB | −7.8 dB | −3.0 dB |
This is left as it is because changing either filter would change what every existing station of that kind records. Writing an explicit chain is how you get the two to agree — a chain is rendered for both backends from the one specification, so a microphone and a camera given the same chain are filtered the same way.
Like the other per-source settings, the chain is read when capture starts — restart the service after changing it.
Stereo microphones and the Channels setting
The BirdNET model takes one channel, so a stereo microphone’s two channels
have to become one before inference. On mono — the default — that reduction is
a plain average of the two.
For two capsules in the same spot that is harmless. For two capsules a few centimetres apart it is a comb filter: the same wavefront reaches them at slightly different times, and averaging then cancels every frequency where the two arrive out of phase. Measured through this project’s own decode path, a half period of delay costs about 66 dB — the signal essentially disappears — while a quarter period costs 3 dB and a full period costs nothing. Which case a given station sits in depends on the capsule spacing and on where the bird is, so it cannot be answered from a datasheet.
There is a command that answers it on your hardware, in your acoustics:
sudo systemctl stop birdnet-behavior # an ALSA capture device is exclusive
birdnet-behavior --channel-report # add --channel-report-secs 15 for a steadier read
sudo systemctl start birdnet-behavior
It records a few seconds, then prints each channel’s level, the inter-channel delay and the capsule spacing that implies, and what Mono / Left / Right would each hand the model. Run it while birds are actually singing: the cancellation is direction-dependent, so ambient noise alone can look benign on a microphone that loses badly on real song.
Act on the answer by setting the source’s channel pick — left or right
takes one capsule intact instead of averaging two. There is not yet a UI
control for it: set the channels column of the source’s audio_sources row
(mono / left / right / stereo) directly and restart the service.
This matters most for exactly the deployments that can least afford it: a sealed enclosure nobody opens for a season. A station losing 66 dB to its own downmix looks completely healthy from every gauge, dashboard and alert this project has.
Adding a camera or stream
Under Cameras and streams, Add stream opens a short form: the stream’s URL, a friendly label, and — for an RTSP camera — its transport and the tracks to request. Saving adds the row; restart for capture to pick it up.
The URL decides the protocol:
| URL | What it is |
|---|---|
rtsp://host:554/stream, rtsps://… | An IP camera. The only protocol with a transport and a choice of tracks. |
http://…, https://… | A direct or Icecast stream (MP3, AAC, Ogg), or an HLS playlist (…/index.m3u8). |
rtmp://…, rtmps://… | A stream on an RTMP server, read from it (the station does not accept a push). |
udp://0.0.0.0:5004 | MPEG-TS over UDP, sent to this station on that port. Write the address out — the browser refuses udp://@:5004. |
srt://host:8890?streamid=read:garden | An SRT stream. |
Credentials go where the protocol puts them — user:pass@host in the address, an SRT passphrase=, a token= in a signed URL. The station hides all three in its logs and support bundle. A URL that starts any other way is refused when you save it, with the list above, rather than saved and left to fail at every restart.
Where the credentials are visible. ffmpeg 6.1 takes its input address as an argument (-i), and RTSP takes its user and password only from that address, so the station passes the whole address, credentials included. Anyone who can list processes on the host sees rtsp://user:pass@camera/… in ps or /proc/<pid>/cmdline while the stream runs. On a station where you are the only user that is nothing; on a shared host, give the station a camera account that can only read the stream, or mount /proc with hidepid=2 so users see only their own processes. The Docker image shows it only inside the container.
There is a way round it that the station does not use yet. ffmpeg’s concat demuxer reads its list from a file, and opens each file in it as an input of its own, RTSP included, with the options on the lines after it. Checked on ffmpeg 6.1.1 against a small RTSP server that answers 401 until it gets the right Basic credentials (tools/rtsp-probe/rtsp_auth_server.py): the camera accepted the credentials, the audio was byte for byte what the direct command captured (32,320 bytes both ways), and /proc/<pid>/cmdline held the path of the list file and no password. What stops it being the default is the work around it: the file has to be written mode 0600 in a private directory for every start and removed after it, the stall and transport options move into option lines, the same change has to reach the live view, the HLS stream and the source probe, which each open the address themselves, and none of it has run against a real camera. See docs/TECH_DEBT.md, TD-5.
rtp:// is not on the list. A bare RTP stream does not say what it carries: ffmpeg reads one carrying G.711, but not one carrying AAC, which needs a description file it has no way to be given. Send MPEG-TS over udp:// instead.
Every protocol was checked end to end against a MediaMTX 1.21.2 server and ffmpeg 6.1 — each recorded its own test tone — and against a stalled server: a source whose server stops answering is restarted on its own, and resumes when the server does. Two things differ from a camera:
- A UDP stream has no live audio. A UDP stream reaches one reader, and the station’s capture is it, so the Live view lists it as spectrogram only, and
/streamanswers503with that reason. - An HLS stream is slow to notice a stall. Its player retries the playlist before giving up, so a server that stops answering was noticed after 52 seconds, where the others took 5 to 20.
Stream capture needs
ffmpegon the host. The bare-metal installer installs it automatically when your config has anRTSP_URL(re-runsudo bash install.sh repairif you add a stream later), and the Docker image already bundles it. Withoutffmpeg, these sources record nothing.
Audio-only cameras
A camera serves only a few video viewers at once. By default the station asks for every track the camera offers and throws the video away, which takes one of those slots for as long as it listens, and another whenever someone listens live from the dashboard. On a camera with few slots that can leave its own app unable to show the picture.
Set Tracks requested to audio track only and the station sets up the audio track alone (ffmpeg’s -allowed_media_types audio), for capture and for listening live. Checked against MediaMTX 1.21.2: the station’s session took one track instead of two.
The default stays audio and video, because a camera that sends no audio then fails visibly: the source shows as down, and the log names the camera’s refusal (for example method PLAY failed: 501 Not Implemented). If that happens after switching to audio-only, switch back.
Finding a USB mic
arecord -l
# card 1: PRO [Comica_Traxshot PRO], device 0: USB Audio [USB Audio]
# ^ index ^ id
arecord -D plughw:CARD=PRO,DEV=0 -d 3 /tmp/test.wav && aplay /tmp/test.wav
Set the device in .env (BIRDNET_ALSA_DEVICE=…) or via the CLI flag — see Configuration.
Name the card, don’t number it
A card index (the 1 in plughw:1,0) is assigned in detection order and is
not stable. It changes when USB devices are re-enumerated, and a reboot is free
to do that.
This is not hypothetical. During an on-device acceptance run, the same
microphone on the same Raspberry Pi 4 was card 1: PRO before a cold reboot and
card 3: PRO afterwards. The station came back up, served a perfectly healthy
dashboard, and recorded nothing — the capture supervisor retried a device that
no longer existed, indefinitely. Nothing was broken except a number.
The id (PRO above) does not move. Address the card by it:
ALSA_CARD=plughw:CARD=PRO,DEV=0
CARD is a first-class ALSA argument, not a trick: alsa-lib’s own alsa.conf
declares pcm.plughw { @args [ CARD DEV SUBDEV ] } with @args.CARD { type string }. A fresh install now writes this form automatically when it can, and
falls back to the index only when the id would be ambiguous — two identical
microphones report the same id, and then only the index can tell them apart.
After editing the config, apply it with sudo bash install.sh repair.
A station still addressing its card by index is told so twice. --doctor
grades a resolving index as an advisory naming the exact CARD= form for the
card it resolved to (an absent index was already a warning). And at every
start the station writes the id it finds behind each index-form device into its
boot journal and compares it with the last start: if plughw:1,0 was PRO
then and is something else now, that is the audio_card_moved boot anomaly,
which reaches /api/v2/health, the station-health notification, and the log
as an error, because the station is recording from a different device than
it was set up with.
Pinning your own names (recommended for multi-mic stations)
The id comes from the device’s own firmware, so two identical microphones give
you two cards called PRO. usb-audio-mapper
solves this properly: it writes a udev rule keyed on vendor/product and USB
port path, setting ATTR{id} to a name you choose.
sudo ./usb-audio-mapper.sh # interactive; reboot when it asks
arecord -l # now shows your chosen name as the id
Then set ALSA_CARD=plughw:CARD=<your-name>,DEV=0. The name survives reboots,
re-plugging, and swapping identical devices between ports — which is the only
way to keep several microphones straight on one station.
--doctor validates either form and, if the configured card is missing, names
the card that is present and prints the exact line to set.
Listening live
The Recordings → Live view (/recordings?view=live; the old /listen redirects there) plays whatever the station is recording right now.
It does not open the microphone itself — an ALSA capture device only allows one
opener, so anything that tried would be refused with Device or resource busy
for as long as recording was in progress, which on a working station is always.
Instead, capture publishes the audio it is already writing to disk, and the live
stream is a second reader of that.
Two things follow from this that are worth knowing:
- Live audio is exactly what the detector hears, including any per-source gain you have set. If the live stream sounds clipped, so does the audio being classified.
- Live audio follows capture. If a source is paused — outside the recording schedule, or inside its quiet window — or is down, the Live tab reports that the source is not recording rather than playing silence. Station Health will say why.
The source picker on the Live view chooses what you hear. What everyone else
hears when they arrive without choosing — and what /stream serves with
no ?source_id= — is the station’s listen default, set with the Make
listen default button on a source’s row on this page. Unset, the first
working source serves, which is the whole story on a one-microphone station.
If the chosen source is later disabled or removed, the stream falls back to the
first working source rather than going silent; the journal says so when it does.
Live audio still needs ffmpeg installed: the station uses it to encode the
stream for the browser. --doctor warns if it is missing.
On a phone, Live uses HLS. The MP3 stream is one long download, and a phone that moves from Wi-Fi to mobile data ends it for good: the tab goes silent and stays so. Where the browser plays HLS itself — Safari, iOS and Android Chrome — the Live view asks the station for an HLS stream instead: a short playlist of two-second AAC segments that the player keeps refetching, so it rides out a handover. It starts a few seconds slower than MP3, because the station waits for two segments before answering. Desktop browsers without native HLS keep the MP3 stream, and so does any browser whose HLS start the station refuses.
Everyone listening to one source at one pitch shares one encoder, and HLS streams count against the same four live-stream slots as MP3. A stream stops when its last listener leaves, or a minute after nothing has fetched it, and its segments — kept in the service’s private temporary directory — are deleted with it. The API is in the REST API reference.
If you cannot hear the high notes
Age-related hearing loss takes the top of the range first, and a great deal of warbler, kinglet and treecreeper song lives above 8 kHz — where a station can hear it perfectly well and its owner cannot.
The pitch control beside the Listen button on /recordings shifts the live
audio down, into a band that still works. Choose one of the downward
presets, and your choice is remembered in that browser: hearing is a property
of a person, so two people listening to the same station at the same time each
get their own setting. (There is also one upward option, which is what you want
for bat calls rather than for hearing loss.)
Changing the pitch reconnects the stream — the shift is applied by the encoder on the station, not in your browser — so expect about a second of silence.
For saved clips the equivalent is --freq-shift-hz (BIRDNET_FREQ_SHIFT_HZ,
config key FREQ_SHIFT), applied when the clip is written. The same rule applies:
negative shifts down and is the direction that helps; a typical value is
-3000.
It is a shift in hertz, not a ratio. Every frequency moves by the number you
give: with -3000, an 8 kHz song is heard at 5 kHz, and the clip’s length is
unchanged. Both the live stream and saved clips do this with ffmpeg’s
afreqshift filter, which needs ffmpeg 4.3 or newer. If the ffmpeg on the
station lacks it, a saved clip is kept unshifted and a warning says so; there
is no other tool to fall back on (sox cannot shift by an amount of hertz). A
shifted clip still carries its species, confidence and date tags. (Releases up to
v0.17.1 scaled frequencies by a ratio instead, so -3000 turned 8 kHz into
7.5 kHz and slowed the clip by 6.7 %, measured with ffmpeg 6.1.1 on an 8 kHz
tone, while this page said otherwise.)
Releases before this one documented that setting backwards, saying a positive value helped high-frequency hearing loss. It does the opposite. If you set a positive value on that advice, negate it.
Evening out the level of saved clips
Clips are written at whatever level the microphone delivered, and a gallery of them recorded across a season is not level: the listener rides the volume control between every one, and a quiet clip at the end of a playlist gets missed. Settings → Capture → Audio Capture → Even out clip volume fixes that. It measures each saved clip’s integrated loudness (ITU-R BS.1770, the measurement behind EBU R128) and applies a single gain.
Off by default, because a clip is an archival record as well as something to
listen to and changing what is in it should be your decision. The choices are
−14 LUFS (loud, the streaming convention), −18 LUFS (recommended) and
−23 LUFS (the EBU R128 broadcast reference, noticeably quiet on a phone).
BIRDNET_CLIP_TARGET_LUFS sets the same thing from the environment, and wins
over the settings page when both are set.
Four things worth knowing:
- It never touches the audio the classifier hears. The gain is applied when the clip is written, long after the analysis window has been scored. A gain applied before inference would move every confidence score and make two stations’ thresholds mean different things.
- A quiet, peaky clip will not reach the target. One loud wing-beat over a
distant song cannot be brought up by a single gain without a sample clipping,
so the clip is turned up only as far as the ceiling allows and no further —
BIRDNET_CLIP_PEAK_CEILING_DBFS, −1 dBFS by default. - That ceiling is a sample peak, not an ITU true peak. The station does not oversample to find inter-sample peaks; the default leaves about a decibel of headroom for them, which is the usual allowance for ordinary material.
- The clip says what was done to it. A normalised WAV’s RIFF INFO comment
carries
Normalised to -18.0 LUFS from -31.4, so the change is visible from the file rather than only from the setting. Re-normalising is a no-op: measuring an already-normalised clip returns the target.
Common pitfalls
- USB hubs can drop audio under load — prefer a direct port on the Pi.
- RTSP over UDP drops packets on busy Wi-Fi — leave the per-source transport on
auto(TCP) for stability; only forceudpfor a camera that requires it.
Recording & Retention
How audio is captured, segmented, and eventually purged.
Capture & segmentation
Incoming audio is split into fixed-length segments before inference. Two settings shape this:
| Setting | Env var / flag | Default | What it does |
|---|---|---|---|
| Segment duration | BIRDNET_SEGMENT_DURATION / --segment-duration | 15 | Length (seconds) of each recorded segment: a whole number from 3 to 600. |
| Overlap | BIRDNET_OVERLAP / --overlap | 0.0 | Seconds of overlap between consecutive analysis chunks. A small overlap (e.g. 1.5) helps catch calls that straddle a chunk boundary, at some CPU cost. |
Recording schedule
By default the station listens all day. To record only during chosen windows, set a schedule:
BIRDNET_RECORDING_SCHEDULE=all-day # default
The same choice is on Settings → Capture → Location & Recording Schedule. Set the variable only to pin it: an environment variable wins over the settings page, which then shows your choice while the station runs this one.
The schedule is all-day, solar (sunrise to sunset, from your location) or
fixed:HH:MM-HH:MM (UTC). The Settings page and PUT /api/v2/settings refuse
anything else, and a fixed window that does not end after it starts: the
scheduler cannot express one that crosses midnight, so it would be ignored and
the station would record all day. A RECORDING_SCHEDULE like that in birdnet.conf
is a --doctor warning, not a refusal. The clip format (wav, mp3, flac, ogg)
and the species-info site (ebird, allaboutbirds, none) are held to the
same lists --doctor uses for the config file.
Why there is no Opus or AAC clip format (birdnet-go has both). Measured on 2026-10-21 with the project’s own decoder on a 3 s clip encoded four ways: WAV and Ogg Vorbis decode; Opus fails with “unsupported audio codec” and AAC in an .m4a with “no suitable format reader found”. The spectrogram decodes the stored clip with that decoder, and the recording-file parser accepts only wav, flac and mp3 names, so an Opus or AAC clip would have no spectrogram until the decoder gained an ffmpeg fallback (symphonia has no Opus decoder at all). The encoders are there (ffmpeg 6.1.1 on Ubuntu lists libopus and aac; the Docker image installs that ffmpeg; Fedora’s ffmpeg-free was not checked), but a lossy clip format that the rest of the station cannot read is worse than the three it has. Ogg Vorbis already gives a small lossy clip.
The scheduler computes sunrise and sunset from your latitude/longitude, so schedules can be anchored to solar events (e.g. the dawn-chorus window) rather than fixed clock times. This is also what powers the day/night cues in the dashboard and the kiosk night mode.
Set your location accurately — sunrise/sunset, the species-frequency filter, and any solar-anchored schedule all depend on it.
Privacy threshold
BIRDNET_PRIVACY_THRESHOLD (default 0.0, disabled) discards every analysis window in which the model’s confidence for a human class (speech, whistling, other human sounds) reaches the value, and the windows either side of it, so casual conversation near the mic isn’t written to disk. The confidence is read from the model’s output before the detection threshold applies, so the setting binds on its own: changing the detection threshold does not change what the privacy filter does. Lower values suppress more. 0.02 is a usual starting point; lower it (towards 0.01) if the microphone is near a patio or path, and raise it if birdsong is being suppressed.
A saved clip is longer than the window it was detected in (see EXTRACTION_LENGTH and PRE_CAPTURE_SECS), so the filter also drops any detection whose clip would reach a window where it heard a voice, however many windows away that is. And while the filter is on, a clip is cut from its own recording segment only, never extended into the segment before or after: the filter judges each segment on its own, so it cannot vouch for a neighbour’s audio. A detection within a second or two of a segment’s start or end therefore gets a shorter clip.
Retention — how clips are purged
Retention is disk-based by default, with an optional age limit on top:
- Keep Clip Audio (days) (
BIRDNET_CLIP_RETENTION_DAYS,--clip-retention-days, default0= keep for ever) reclaims the audio of detections older than N days. - The disk manager purges the oldest recordings once the disk crosses
DISK_PURGE_THRESHOLD(default 95%, inbirdnet.conf). - At most
BIRDNET_MAX_FILES_PER_SPECIES(--max-files-per-species, default0= unlimited) clips are kept per species. - Locked clips are never purged — lock anything you want to keep permanently from the Today page or a species’ recordings.
The detection rows in the database are kept regardless; purging only removes the audio files, not the history. Once a day the station also reconciles the database with the recordings folder: a detection whose clip is no longer on disk, whatever removed it, is marked as pruned so its play button goes away, and any half-written .part file older than an hour is removed. The count reaches the log and the birdnet_orphaned_clips metric. The storage breakdown on the Backups page shows how much space recordings are using.
Notifications & Integrations
BirdNet-Behavior can tell the rest of your world when a bird shows up — from a Telegram ping to a Home Assistant entity.
Notification center
The Notifications page (/notifications) shows your channels and a log of recent events, with per-channel send counts, delivery status, and the last-sent time.
Channels
- Direct push — paste one or more notification URLs under Settings → Alerts →
Notifications (“Notification URLs”), or set
BIRDNET_NOTIFY_URLS. The syntax is Apprise’s, so anything you already have written down works, but the station sends it itself: no Python, noapprisebinary, no subprocess per detection. Handled natively:discord://,slack://,tgram://,ntfy:///ntfys://,gotify:///gotifys://,pover://, andjson:///jsons://for a plain webhook. - Apprise — for the other ~70 services. Point
BIRDNET_APPRISE_URLat an Apprise API server, orBIRDNET_APPRISE_CONFIGat anappriseconfig file. If every URL in that file is one of the schemes above, theappriseCLI is never invoked and you do not need it installed. - Email — direct SMTP/STARTTLS with a per-species cooldown, configured under Settings → Alerts → Email Alerts.
- BirdWeather — upload detections to your BirdWeather station. Set the token under Settings → Alerts → Notifications, or with
BIRDNET_BIRDWEATHER_TOKEN.
Either surface works for all three: the environment variable wins if you set it,
otherwise the value you save under Settings → Alerts → Notifications is the one that
sends. The same applies to the minimum notification confidence
(BIRDNET_NOTIFY_CONFIDENCE, default 0.8), the trigger mode, the cooldown,
the species allow/exclude lists, and the message templates. Changes take effect
on the next restart.
When to notify
The Notification Trigger is one of: every detection; the first of each species, each day; a species seen five times or fewer in a week; both of those (not every detection); or never. These are BirdNET-Pi’s three switches (APPRISE_NOTIFY_EACH_DETECTION, _NEW_SPECIES_EACH_DAY and _NEW_SPECIES) in every combination that behaves differently: “every detection” already announces everything the other two would. In birdnet.conf it is APPRISE_TRIGGER, which takes a word or a comma-separated list (each, new-species, new-species-daily, off).
When more than one rule matches a detection, one message is sent and $reason names each of them (first time today, only seen 3 times in last 7d); BirdNET-Pi sends one message per rule. A station moved from BirdNET-Pi with all three switches at 0 has the trigger never, as it did there. Moving a birdnet.conf is covered by Migrating from BirdNET-Pi.
The Send Test Push Notification button under Settings → Alerts (/station/alerts) uses the values saved on the
Settings page, so a successful test means live detections will notify too.
Message templates
The title and body of a push notification are templates, set under
Settings → Alerts → Notifications (or APPRISE_TITLE_TEMPLATE / APPRISE_BODY_TEMPLATE
in the config file). They use BirdNET-Pi’s $variable names, so a template
moved over from a BirdNET-Pi station keeps working:
| Variable | Replaced with |
|---|---|
$comname, $sciname | Common and scientific name |
$confidence, $confidencepct | Confidence as 0.92, and as 92 |
$date, $time, $week | The detection’s local date, time, and ISO week |
$latitude, $longitude | The station’s coordinates to six places; nothing at all when the station has none |
$reason | Why this detection notified: detection, first time today, or only seen N times in last 7d — whichever the trigger mode found |
$cutoff, $sens, $overlap | The confidence bar this detection cleared, and the sensitivity and overlap the station was running with |
$listenurl | The detection’s own page, with its clip and spectrogram |
$friendlyurl | The same address as a Markdown link, [Listen here](…) |
$image, $flickrimage | The species picture, served by the station |
A value the station does not have renders as nothing, never as a placeholder
such as 0.000000.
The three link variables need the station’s address as the phone sees it.
BNB_PUBLIC_URL is read first, then BNB_BASE_URL, then the machine’s name on
the local network (http://<hostname>.local:8502, the same address the
installer prints). If none of these can be worked out the links are left empty
rather than pointing at localhost, which would be the phone itself. A station
that is only reachable through a reverse proxy or a VPN name should set
BNB_PUBLIC_URL.
Keeping the credential out of the environment
A notification URL carries its bot token inside the URL, and an environment
variable is readable by docker inspect, by anything with the process’s
/proc/<pid>/environ, and by anything that logs its own environment. Docker and
Kubernetes both solve this by mounting the secret as a file; this station accepts
that convention:
| Instead of | Set | To the path of a file holding the value |
|---|---|---|
BIRDNET_NOTIFY_URLS | BIRDNET_NOTIFY_URLS_FILE | e.g. /run/secrets/birdnet_notify_urls |
BIRDNET_APPRISE_URL | BIRDNET_APPRISE_URL_FILE | |
BIRDNET_BIRDWEATHER_TOKEN | BIRDNET_BIRDWEATHER_TOKEN_FILE | |
BIRDNET_MQTT_PASSWORD | BIRDNET_MQTT_PASSWORD_FILE | |
BIRDNET_HEARTBEAT_URL | BIRDNET_HEARTBEAT_URL_FILE |
services:
birdnet:
environment:
BIRDNET_NOTIFY_URLS_FILE: /run/secrets/birdnet_notify_urls
secrets:
- birdnet_notify_urls
secrets:
birdnet_notify_urls:
file: ./secrets/notify_urls.txt
Four things worth knowing, each reported in the journal at startup:
- The direct value wins. Set both and the file is not read; the station says so at warning level rather than silently choosing one.
- A file that cannot be read, or that is empty, leaves the feature off — and says so at error level. A station that sends no notifications because a mount path had a typo looks exactly like one that was told to be quiet, so this is the one case worth an error line.
- Surrounding whitespace is trimmed, so the trailing newline every secret
file has is harmless; inner newlines are kept, so a
NOTIFY_URLSfile may list one URL per line. - A value read from a file is not copied into the settings table, so it stays out of the database and out of every backup, restore bundle and support archive taken from it. It will not appear on the Settings page either — which is the point.
BIRDNET_APPRISE_CONFIG has no _FILE form, because APPRISE_CONFIG_FILE
already exists and means something different: the path of an apprise
configuration file, which apprise itself reads. The SMTP password has none
either — it lives only in the settings table, set from the admin UI.
MQTT & Home Assistant
A pure-Rust MQTT 3.1.1 client publishes detections to any broker (Mosquitto, Node-RED, EMQX, …) — no external broker library required.
BIRDNET_MQTT_HOST=192.168.1.10
BIRDNET_MQTT_HA_DISCOVERY=1 # publish Home Assistant auto-discovery config
A broker the station cannot reach is reported, not just retried: after ten
minutes without a session the station-health alerts carry an mqtt
condition with the last error, /api/v2/health says "mqtt": "disconnected",
and Home Assistant shows the station offline in the meantime. Test all
channels on the notifications test page publishes a real message to
<prefix>/test (never retained) and sends a test email through the configured
notifier, beside push and BirdWeather; a run in which no channel is configured
says that nothing was tested rather than that everything passed.
Three more integrations have a button of their own on that page, each showing the real answer rather than a reassurance: MQTT broker (publishes one message and reports the broker’s refusal, the TLS error or the connect error as it came), eBird (asks eBird for the recent observations around the station with the configured key, so a refused key reads “eBird refused the API key (403)”, not “unreachable”) and Weather (fetches from the configured provider). A button is disabled, with the setting to change beside it, until that integration is configured; one that is configured but cannot start (an eBird key with no station coordinates, an unknown weather provider) is enabled and answers with that reason. They test what the running station was started with, so a value saved a moment ago shows up after a restart.
TLS
BIRDNET_MQTT_TLS=1 # port defaults to 8883 when this is on
BIRDNET_MQTT_CA_FILE=/etc/birdnet/mqtt-ca.pem
BIRDNET_MQTT_TLS_SERVER_NAME=broker.lan # when connecting by IP
The certificate is always verified, against the system trust store plus
anything in BIRDNET_MQTT_CA_FILE. There is no option to skip verification:
that is the setting that gets switched on during setup and never switched off,
and an unverified TLS connection carrying the broker password is worse than a
plaintext one because it looks safe. Setting BIRDNET_MQTT_CA_FILE turns TLS
on by itself — configuring a trust anchor and then connecting in plaintext is
never what was meant.
Which certificate goes in that file depends on your broker:
- Behind a private CA — the CA’s certificate. Pointing it at the broker’s
own certificate fails with
UnknownIssuer. - Self-signed, no CA — the broker’s own certificate.
Either way the broker’s certificate must carry CA:FALSE. A plain
openssl req -x509 — what most “make a self-signed certificate” recipes give —
defaults to CA:TRUE, and the connection then fails with CaUsedAsEndEntity.
Add -addext basicConstraints=critical,CA:FALSE when generating it.
A broker that asks for a client certificate
Brokers set to require mutual TLS (Mosquitto require_certificate true, AWS
IoT, EMQX with verify_peer) want the station to present a certificate of its
own:
BIRDNET_MQTT_CLIENT_CERT=/etc/birdnet/mqtt-client.pem
BIRDNET_MQTT_CLIENT_KEY=/etc/birdnet/mqtt-client-key.pem
Both are PEM files and both are needed; naming one without the other is refused before any connection is made, and so is a key that does not belong to the certificate. Setting a client certificate turns TLS on by itself. The same two fields are under Settings → Alerts → Home Assistant & MQTT. Paths are to files on the station, which is how birdnet-go’s “managed” certificates are stored too; this station does not take an uploaded private key through the browser.
birdnet-behavior --doctor has an MQTT TLS line that opens each file and
checks the pair without connecting, so a typo in a path is found before the
first detection rather than as one warning per detection. It does not report
when a certificate expires: reading a certificate’s validity dates needs an
X.509 parser the station does not carry.
With --mqtt-ha-discovery, the station registers itself in Home Assistant automatically, so the latest detection, species count and confidence appear as entities you can put on a dashboard or trigger automations from.
Alerts on the station itself
The rules below watch detections. A second kind, on the same page (Settings → Alerts), watches the station: a measurement it already takes, compared against a threshold you choose.
The station alerts on a fixed set of conditions already — disk over 85 %, a source flapping, the clock adrift, the analytics copy falling behind, a classifier failing every window it is given. Those are good defaults and they are not everyone’s. The rule that catches a dying microphone at a particular station is “tell me when the hourly detection count drops below what it normally is here”, and no number chosen in this project can be that.
Seven measurements are available: disk in use (%), memory in use (%), CPU temperature (°C), detections in the last hour, seconds since the last detection, capture restarts in the last hour (the worst source), and uploads waiting to be sent. Each rule is a measurement, a direction (above or below), and a threshold.
Two things follow from where these live:
- A rule that trips has to stay tripped for three polls — fifteen minutes — before it alerts, and you get a notice when it recovers. That is the same debounce and the same episode handling the built-in conditions use, so a momentary spike does not wake anybody and a long fault is not announced every five minutes. There is no per-rule cooldown to set, because that is what the episode is.
- A firing rule appears on
/api/v2/health/conditionsalongside the built-in ones, and is delivered through the same notifier, logged in the same notification log, and parked in the same store-and-forward outbox if the destination is unreachable.
A measurement the station cannot read right now — CPU temperature on a board with no sensor, capture restarts with no capture supervisor — produces no alert. “Cannot tell” is not “fine”, but it is not a fault to wake somebody for either, and treating a missing reading as zero would make every below rule fire for ever.
A rule that could never stop firing is refused when you create it: a percentage is never below zero or above 100, so “disk above −1” is a notification, not an alert.
Alert rules
The Rules engine (Settings → Alerts, /station/alerts#rules; the old /admin/rules redirects there) fires conditional actions on detections — for example, a webhook only when an owl is heard at night above 0.7 confidence, or a rule that suppresses a noisy false-positive species. Each rule matches on species pattern, confidence range, hour-of-day and day-of-week.
A webhook rule can authenticate, so it can target endpoints that need a key rather than only ones that authenticate by URL alone:
| Scheme | Sends | Credential field |
|---|---|---|
| Bearer token | Authorization: Bearer <token> | the token |
| Basic | Authorization: Basic base64(user:password) | user:password |
| Custom header | <name>: <value> | the value, plus a header name |
Leaving the scheme on None keeps the request exactly as it was — which is what every rule created before this existed does. The credential is stored in the station’s own database, is never rendered back into the page, and is redacted from logs and from an exported rule set.
Cooldown. A calling owl is detected in every three-second window, so a webhook rule on Strix* would post once per window for as long as it called. A webhook rule therefore has a cooldown: after it posts about a species it stays quiet about that species for the number of minutes you set, and the same rule still posts at once for a different bird. The create form asks in whole minutes and starts at 5. 0 posts for every matching detection, the behaviour of a rule made before the cooldown existed, and those rules keep it (the list says no cooldown). The longest is 10080 minutes, seven days; anything that is not a whole number of minutes in that range (a negative, a fraction such as 1.5, text, a blank field, a number past the limit) is refused with the range named and the rule is not created, never shortened to fit. Only webhook rules have a cooldown, so the field is part of the webhook section of the form: a Log rule writes its entry for every match and a Suppress rule must suppress every match, because one that stopped after its first would let the later notifications through. The list shows each webhook rule’s cooldown (cooldown 5 min, cooldown 1 h, or no cooldown) and an exported rule set carries it, so a rule moved to another station keeps it; a rule with no cooldown writes no cooldown_secs at all, and a file written before the cooldown existed imports with none. The cooldown runs on the detection’s time and not the clock on the wall, so a backlog replayed after a power cut posts once per window of the recording, not once for the whole night.
Test on a rule fires its action once, immediately, with an unmistakably
synthetic detection (Test Detection (not a real bird)) and reports the HTTP
status. Finding out an endpoint is wrong is worth doing when the rule is
written rather than the first time an owl calls at 3 a.m.
Export downloads the whole rule set as JSON with every credential replaced
by ***REDACTED***, so it is safe to paste into a forum thread when asking for
help. Export with credentials is the backup-and-restore form and that file
is a secret. Import adds the rules in a pasted set — it never replaces what
is already there — and names any rule whose credential arrived redacted, since
those will fire unauthenticated until one is entered. One unusable entry is
reported and skipped rather than discarding the rest of the paste.
The file carries both kinds of rule: the detection rules above and the station alerts that watch disk, memory, temperature and the rest. They are different mechanisms — one matches a detection as it arrives, the other compares a measurement every five minutes — but an operator moving a station or asking for help wants one file, not two. Metric rules have no credential, so the redaction question does not apply to them.
The file records a format version. A rule set written before metric rules existed still imports, as a set with none; a set from a newer station is refused with a message naming both versions rather than being read as a set of rules with fields missing. A metric this station does not implement is named and skipped, not silently dropped — otherwise a file from a newer station would import looking complete while missing the rules that mattered. Import applies the same validation the form does, so a rule that could never stop firing (“disk above −1”) is refused either way.
Remote Access & Security
By default BirdNet-Behavior binds to 0.0.0.0:8502 — reachable from any device on your LAN. Viewing the dashboard is open (no login); signing in is required for the /admin* panel, the Settings management tabs and every action that changes something — with a password a fresh install sets for you. For a station reachable from beyond the LAN, private mode puts the dashboard itself behind that sign-in. This page covers reaching the station from your network and from elsewhere, safely.
On your local network
Out of the box the dashboard is already reachable from other devices. Browse to http://<pi-ip>:8502 from any device on the network — find the Pi’s address with hostname -I.
The
0.0.0.0default exposes the dashboard to everyone on your network. That’s usually fine at home: the read-only views are open, and the/adminpanel is protected by the auto-generated password (see below). On an untrusted network (a shared flat, public Wi-Fi), confirmCADDY_PWDis set and consider restricting the bind.
Restrict to this device only
To make the dashboard reachable only from the machine itself, bind to loopback:
BIRDNET_LISTEN=127.0.0.1:8502
(Or answer “restrict to this device” in the interactive installer.) Then reach it remotely with an SSH tunnel (ssh -L 8502:localhost:8502 pi@host) or a VPN — see the private-tunnel section below.
Built-in HTTPS
The server terminates TLS itself. It is off by default — a station on a trusted LAN behind a reverse proxy has no need of it, and turning it on for everyone would break every existing bookmark.
The one-command version
birdnet-behavior --tls-mode self-signed
HTTPS comes up on 8503 (plain HTTP keeps answering on 8502) and the log prints one path:
self-signed HTTPS: import this CA file once to stop the browser warning
ca=/home/pi/BirdNet-Behavior/tls/local-ca.crt
(The folder is tls beside the database, or --tls-dir: ~/BirdNet-Behavior/tls for the account the bare-metal service runs as — pi here — and /data/BirdNet-Behavior/tls in Docker.)
That file is a small certificate authority the station generated for itself,
and the server certificate is signed by it. Import the CA once — into your
browser, your OS trust store, or curl --cacert — and the warning stops. It
keeps working when the server certificate rotates (the CA is good for ten
years; the certificate it signs for 397 days and is replaced a month before it
expires — checked once a day while the station runs, and swapped in without a
restart), so you do this once per station, not once per year. On a Pi whose
clock has not been set yet (no battery-backed clock, NTP not yet reached) no
certificate is minted: the station serves plain HTTP on the same address,
says so in the journal and in --doctor, and restarts itself to mint one as
soon as the clock is set.
The CA is limited to this station’s own names: localhost, .local
names, the loopback, private and link-local address ranges, and any extra name
you gave with --tls-hostnames. Anything that can read the CA’s private key
(it is local-ca.key, mode 0600, owned by the service user) can therefore not
mint a certificate for a public website that your devices would trust. A phone
joining the network, a new DHCP lease or a new .local name needs nothing from
you. A new public name added to --tls-hostnames does: the station makes a
new CA for it, logs a warning saying so, and you import the new local-ca.crt
on the devices that trusted the old one. The same happens once, when you
upgrade from a release before CAs were limited this way.
To serve only HTTPS on the usual port, point both at the same address:
birdnet-behavior --listen 0.0.0.0:8502 --tls-mode self-signed --tls-listen 0.0.0.0:8502
Or keep 8502 open and have it redirect:
birdnet-behavior --tls-mode self-signed --tls-redirect
With a real certificate
If you already have one — from your own ACME client, an internal CA, or a purchase:
birdnet-behavior --tls-mode manual \
--tls-cert /etc/letsencrypt/live/birds.example.com/fullchain.pem \
--tls-key /etc/letsencrypt/live/birds.example.com/privkey.pem
Both files are re-checked once a minute and re-read when they change on disk,
so a certbot renew in the small hours is serving within a minute, from the
next handshake — no restart, no cron hook.
What the browser shows
Settings → Access → Secure connection says which of the three modes the
station is in. In self-signed mode it shows when the certificate runs to (read
from the certificate’s own record, not the configured length) and offers the
station’s local CA certificate for download, so you can add it to a phone or
computer once instead of clicking through the browser warning every time. The
CA’s private key is never served. The page has no upload or generate button, on
purpose: the page used to fix a plain-HTTP station is itself plain HTTP, so a form
for a private key would send the key across the network in the clear; the
certificate and key are files on the station, and self-signed already generates
and renews its own.
Checking it before you rely on it
--doctor does exactly what startup does, early enough to be useful:
$ birdnet-behavior --doctor
[ PASS ] HTTPS — self-signed on 0.0.0.0:8503, covering localhost, pi, pi.local, 127.0.0.1 (valid 397 days)
[ PASS ] HTTPS — import /home/pi/BirdNet-Behavior/tls/local-ca.crt to stop the browser warning
A mistyped path or a key that does not match its certificate is a [ FAIL ]
here rather than a service that restart-loops after you have gone inside.
Every setting has a BIRDNET_TLS_* environment variable and a config-file key;
see .env.example.
Do NOT expose it directly to the internet
Built-in HTTPS encrypts the traffic; it does not make the station safe to
port-forward. There is no rate-limited login lockout, no WAF, and the
self-signed mode carries no publicly-trusted name. Never port-forward
8502/8503 straight to the internet. Use a VPN or a private tunnel, or put
it behind a reverse proxy that terminates HTTPS with a real certificate and
adds authentication.
Reverse proxy with HTTPS
Caddy (simplest — automatic HTTPS)
Caddy fetches and renews a Let’s Encrypt certificate for you. A whole Caddyfile can be:
birds.example.com {
reverse_proxy 127.0.0.1:8502
basic_auth {
# generate the hash with: caddy hash-password
birder $2a$14$....hashed-password....
}
}
nginx
server {
listen 443 ssl;
server_name birds.example.com;
# ssl_certificate / ssl_certificate_key from certbot or your CA
location / {
proxy_pass http://127.0.0.1:8502;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade; # WebSocket (live feed,
proxy_set_header Connection "upgrade"; # spectrogram, kiosk)
}
auth_basic "BirdNet-Behavior";
auth_basic_user_file /etc/nginx/.htpasswd; # htpasswd -c … birder
}
Keep the
X-Forwarded-Forline. It is how the station tells visitors apart for its rate limit, its sign-in throttle and the audit log. A proxy that sets onlyX-Real-IPpasses the visitor’s ownX-Forwarded-Forthrough, and a visitor could then claim any address. When the two headers name different visitors the station believes neither and counts the request as the proxy’s own — safe, but every visitor then shares one rate-limit bucket. The log says so once: “a trusted proxy sent X-Forwarded-For and X-Real-IP naming different clients”. For the same reason, if your proxy does not setX-Real-IPitself, have it clear one a visitor sends (nginx:proxy_set_header X-Real-IP "";), so nobody can spend the proxy’s own sign-in throttle by forging it.
Keep the
X-Forwarded-Protoline too. The station marks its sign-in cookieSecurewhen the request reached it over HTTPS: on its own TLS listener, when a trusted proxy says so withX-Forwarded-Proto(orForwarded: proto=), or whenBNB_PUBLIC_URLis anhttps://address and the request was addressed to that host. A proxy that sends none of these, with noBNB_PUBLIC_URLset, gets a cookie withoutSecure, which a browser will also send over plain HTTP.
WebSocket matters here. The live dashboard feed, the spectrogram, and kiosk mode all use WebSockets (
/api/v2/ws/detectionsand/api/v2/ws/spectrogram). Make sure your proxy forwards theUpgrade/Connectionheaders (shown above) or those features will silently stall.
Sharing one hostname: serving under a path
The setup above gives the station its own hostname. If you already run other
services behind one hostname and want the station at
https://home.example/birdnet, set a base path:
BIRDNET_BASE_PATH=/birdnet
The station then serves everything — pages, API, static assets, WebSockets — from under that prefix, and every link, form, redirect and cookie it emits carries it. A visitor who reaches the bare host root is redirected to the prefix, so a mistyped bookmark still lands somewhere useful.
Your proxy must pass the full path through unchanged. The station expects
to see /birdnet/today, not /today. This is the opposite of the usual
“strip the prefix” recipe, and it is the one thing that goes wrong:
home.example {
handle /birdnet/* {
reverse_proxy 127.0.0.1:8502 # NOT handle_path — that strips /birdnet
}
}
location /birdnet/ {
proxy_pass http://127.0.0.1:8502; # no trailing path: adding one makes
proxy_set_header Host $host; # nginx strip the prefix
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Home Assistant ingress works the same way.
A prefix that cannot be used — one containing .., whitespace, or a query
string — is refused and logged, and the station serves from the root. It is
not quietly corrected: a corrected prefix would not match what your proxy
sends, and every request would 404 with nothing to explain why.
Home Assistant, Nextcloud and the rest each need their own
locationblock. Nothing about the base path changes the TLS or authentication advice above.
Built-in admin sign-in
The binary gates the /admin panel itself — no proxy required — using the BirdNET-Pi CADDY_PWD convention for the password. Viewing the dashboard and the read-only /api/v2/* endpoints stay open. Signing in is required for the /admin* panel, the Settings management tabs (/station/capture, /station/alerts, /station/data, /station/settings, /station/access) and every action that changes something — deleting, relabelling, reviewing or locking a detection, approving or deleting a quarantined record, bulk review from Search, and saving the onboarding wizard.
Requesting /admin* without a session redirects (303) to a sign-in form at /login, which issues a session cookie. It is not HTTP Basic Auth, so curl -u user:pass will not work — post the form, or sign in through the browser.
A fresh install auto-generates a strong password, prints it once in the post-install summary, and stores it as CADDY_PWD in birdnet.conf. Sign in with the username admin — the account the dashboard seeds, and the only one that exists until you add more in the admin panel.
CADDY_PWD=a-long-random-password
CADDY_USER is read from the process environment only. Under Docker, where compose passes it through, it renames the sign-in; on a bare-metal install the systemd unit sets no EnvironmentFile, so a CADDY_USER line in birdnet.conf has no effect and the sign-in name stays admin.
After editing the config, restart the service (sudo systemctl restart birdnet-behavior). This is still plain HTTP — only rely on it behind TLS, or on a trusted LAN. Clearing CADDY_PWD leaves /admin open to anyone who can reach the dashboard; if the server binds to a non-loopback address (e.g. the default 0.0.0.0) with no CADDY_PWD set, it logs a prominent warning at startup. The live-detection WebSocket and /api/v2/health are exempt from this auth (they sit outside the gated router, and a browser cannot attach a session to a WebSocket handshake anyway), and are read-only in any case — restrict those at the network layer if you need to.
Admins and viewers
Each account on Settings → Access is an admin or a viewer.
- An admin can do everything the sign-in protects.
- A viewer can open every page behind the sign-in, the settings forms included, but can change nothing: every save, delete, lock, review or other change is refused (
403). The settings forms show a viewer every stored password, token and key masked. - A viewer is also refused the few pages that would hand over credentials or the whole database: the full backup and the backup files, the support bundle, the alert-rules export (webhook URLs carry their tokens), an audio source’s own settings (an RTSP URL carries its password), the
birdnet.conflines the BirdNET-Pi import offers to download, and Diagnostics (/admin/doctor, which prints the MQTT broker and the certificate and model paths). The source’s status pill stays visible, and the model panel on the Capture tab shows a viewer what is wrong without the file path.
Cross-origin requests (CORS)
By default the API allows no cross-origin reads — a website you happen to visit can’t read your station’s data over the LAN. If you serve a separate dashboard from a different origin, allow it explicitly:
BIRDNET_CORS_ALLOWED_ORIGINS=https://dashboard.example.com
List origins by name; * is not supported. The station logs an error, ignores that entry and keeps the others, because a wildcard would let any website read the API. State-changing requests are protected by a CSRF guard regardless of this setting.
Embedding the station in another page
By default no other website can show the station inside a frame: every page
carries Content-Security-Policy: frame-ancestors 'self' and
X-Frame-Options: SAMEORIGIN. With sites listed (below), only the dashboard
(/), the wall display (/kiosk) and the live view (/recordings) change; the
sign-in, /admin, /station, the setup wizard and every other page keep that
policy whatever you list. That is the right default for a page with an
admin sign-in behind it, because a site that can frame the station can lay
invisible controls over it to trick a visitor into clicking (clickjacking). It
also means a Home Assistant card or a Grafana panel cannot show the live view.
To allow it, name the places that may, in birdnet.conf or under Settings → General →
Online services:
EMBED_ORIGINS=http://homeassistant.local:8123, https://abcd1234.ui.nabu.casa
- Each entry is a full origin,
httporhttps, host and optional port, no path.https://*.example.comcovers every subdomain of a domain you own. Name your own host under a shared service rather than the service: a wildcard overui.nabu.casa,github.io,pages.dev,co.ukand the like would let everyone who registers a name there show the station, so those are refused (from a short built-in list of such suffixes, not the full Public Suffix List: a suffix that is not on it is not protected). - There is no way to allow every site. A bare
*, a scheme alone and a wildcard over a whole top-level domain (https://*.com) are refused. tphakala/birdnet-go’sallowembeddingis a switch for all sites at once; here you list the sites, because everyone who asks for this knows where they are embedding from. - One entry the station cannot read refuses the whole list, and then no site
may embed; the journal and
--doctorsay which entry. Dropping just the bad one would silently narrow what you typed. - With a list set,
X-Frame-Optionsis left off (it cannot name a third party) andframe-ancestorsalone decides:'self'plus your origins. Nothing else in the policy changes. - Applies after a restart.
For a page on another site, what you get in the frame is the station as an
anonymous visitor sees it: the sign-in cookie is SameSite=Lax, which a browser
does not send from inside a frame on another site, so signing in does not carry
into the frame. A site is the registrable domain, not the origin, so this does
not hold for the usual set-up: Home Assistant at http://192.168.1.10:8123
framing the station at http://192.168.1.10:8502 (another port), or a sibling
subdomain, is the same site. There the cookie is sent, and if you are signed in
to the station in that browser the framed dashboard shows its administrator
controls, which the embedding page could click for you if it were compromised.
List only places you trust with your browser, and sign out of the station on a
shared screen.
A safer alternative: a private tunnel
For remote access without opening any ports, a mesh VPN like Tailscale or WireGuard is the easiest secure option: install it on the Pi and your phone/laptop, and reach http://<tailscale-ip>:8502 as if you were on the same LAN — encrypted, no public exposure, no certificates to manage.
Checklist
- Keep
CADDY_PWDset so/adminstays protected (a fresh install generates one) — don’t clear it on a non-loopback bind. - Restrict the bind to
127.0.0.1(+ SSH/VPN) if you don’t need LAN access. - Never port-forward
8502directly — always terminate TLS at a proxy or use a VPN for off-LAN access. - Forward WebSocket upgrade headers in the proxy.
Backups & Recovery
The Data tab (/station/data) is where you protect your records: snapshots, exports, storage and the controls you hope you never need.
Three kinds of backup
Snapshots copy the database only. They are small and quick, they run automatically every 7 days, and the station keeps the most recent 14. They live in backups/ beside birds.db — on the station itself. That makes them perfect for undoing a bad import or recovering from database corruption, and useless if the SD card dies.
Full backups bundle the database, your recordings and your config into one .tar.gz you download. This is the one to keep somewhere else. Nothing takes these automatically — download one after any big change, and periodically if your station matters to you.
Offsite copies send each weekly snapshot to an object store or an SSH server, encrypted before it leaves. Off by default; see Offsite backups below. This is what covers the failure the other two do not: the card wearing out, the enclosure flooding, the Pi being stolen.
The automatic schedule runs on elapsed wall-clock time, not uptime, so a station that reboots often still gets its backups: an overdue job runs shortly after the next boot rather than restarting its timer.
Restoring
Restore from file takes a full backup archive and puts its database and recordings in place of the current ones. The station first checks that the disk can hold the archive’s contents with headroom, refuses an archive whose database is not named as this station’s, stops recording detections, unpacks the archive beside the database and integrity-checks the copy there, and only then swaps the files in (the database by rename, so the running process is never left reading a half-written file; recordings merged clip by clip, so clips the archive lacks are kept). Under systemd the station restarts itself to load the result; elsewhere it tells you to.
Restoring is destructive and cannot be undone. It replaces what is on the station now, and the station does not snapshot the current state first. Download a full backup before you restore. Detections stay paused until the restart that follows.
The archive’s contents are not signed or verified — only restore an archive you produced yourself and trust.
Individual snapshots can be downloaded and deleted from the snapshot list, so you can recover a database by hand: stop the service, put the snapshot in place of birds.db, and start it again.
Export
Your detection data is yours, in formats other tools read:
- Detections (CSV or JSON) — every detection the station stands behind, with date, species and confidence. Rows a reviewer rejected, and imported rows on a station that excludes imports from its analytics, are left out; the same rule the charts use.
DateandTimeare the station’s local wall clock with no offset, exactly as BirdNET-Pi wrote them; beside them every row carriesEvent_Date(that wall clock with the offset that was in force, RFC 3339, so the two passes of a repeated autumn hour are told apart) andDetected_At_UTC(the instant), both blank on a row that names no point in time. Every row also says which model made it: the CSV ends inRun_Id,Model_Name,Model_SHA256and the JSON carriesrun_id,model_nameandmodel_sha256per row — the model file’s name and the SHA-256 of its bytes, so a season that spans a model upgrade can be split by which model heard what. The runs themselves (when each started, the labels checksum, the settings in force) are at/api/v2/analysis-runs. The three columns are empty on a row this station did not analyse: imported history, or rows older than the run table. - Species summary (CSV) — per-species totals and first-seen dates.
- eBird checklist — eBird Record Format, one record per species per hour with
Numberwritten asX(present, not counted) and the detection tally in the comment. Only detections at or above a confidence floor (0.75 by default,?min_confidence=) that a reviewer has not rejected are included, and the coordinates are the station’s configured location — blank, never0,0, if none is set. Protocol, observer count, region and completeness are query parameters (?protocol=Stationary&observers=1&state=&country=&complete=false), because they are facts about the submitter rather than the station. - BirdNET-Pi
BirdDB.txt— semicolon-separated, the twelve BirdNET-Pi columns and nothing more, for tools expecting the original format. Same rows as the CSV, without the three model columns: the format has no room for them and its consumers count fields. - Raven selection table — one tab-separated table over every detection that has a clip, in the format BirdNET-Analyzer writes (
Selection,View,Channel,Begin Time (s),End Time (s),Low Freq (Hz),High Freq (Hz),Common Name,Species Code,Confidence,Begin Path,File Offset (s)), so Raven Pro opens it against the recordings folder as a multi-file table. Each selection is placed where the detection sits inside its clip, from the lead-in the extractor recorded; a row written before that was recorded (schema 47) spans its whole clip, and a row with no clip, or no clip length, is left out rather than given a made-up window. The species code is the eBird code from the station’s label file, or the scientific name when it has none. Every clip also has its own table at/api/v2/recordings/<clip>/raven.txtand an Audacity label track (begin,end, label; File → Import → Labels) at/api/v2/recordings/<clip>/labels.txt; the detection page links to neither yet, so paste the clip’s filename in.
Storage & retention
The storage breakdown shows where space is going — the database (including its write-ahead log), your recordings, and the snapshots — measured live, not estimated.
Time-based retention is off by default, not absent: set Keep Clip Audio (days) (CLIP_RETENTION_DAYS, --clip-retention-days) for a rolling window — 0, the default, keeps audio for ever. Two disk-based limits apply alongside it:
- the disk manager purges the oldest recordings once the disk crosses
DISK_PURGE_THRESHOLD(default 95%); MAX_FILES_SPECIESkeeps at most N clips per species, pruned on the daily maintenance tick.
Recordings you have locked are never purged by either, and locking takes effect on the next cycle — no restart needed. When a clip is pruned its detection row survives: your counts, species lists and analytics are unaffected, only the audio is removed.
To lock a clip, open Recordings and use the 🔒 Lock action on a row — or select several and use the bulk Lock button above the grid. Locked rows show an unlock action in the same place.
Offsite backups
A snapshot on the same SD card as the database it came from protects you from a corrupt page and a bad import. It does not protect you from the card, and SD cards in a box on a fence post are a when, not an if.
Set OFFSITE_BACKUP and each weekly snapshot is encrypted on the station and uploaded. Nothing else changes: the local snapshots and their 14-file rotation stay exactly as they were, so this only ever adds a copy.
Encryption is not optional
Your database is a log of what is around your house and when you are there. “Server-side encryption” on a bucket means the provider holds the key; an SFTP host means its administrator does. So the station encrypts before it uploads — argon2id over your passphrase, then ChaCha20-Poly1305 — and there is no setting to turn that off.
OFFSITE_PASSPHRASE=a long passphrase you keep somewhere else
Write it down somewhere that is not this station. There is no recovery: the passphrase is not stored anywhere, not derivable from the backup, and not known to us. A backup you cannot decrypt is not a backup.
At least 12 characters, and the station refuses shorter ones — not as password policy, but because below that the argon2 parameters stop being what protects the file.
Secrets are config-file or environment keys only. There is deliberately no --offsite-passphrase flag: anything on a command line is visible in ps to every user on the machine, is copied into the journal by systemd’s ExecStart=, and lands in your shell history.
To an S3-compatible store
Works with AWS S3, Backblaze B2’s S3 API, Cloudflare R2, Wasabi, MinIO, Ceph RGW and Garage.
OFFSITE_BACKUP=s3
OFFSITE_S3_ENDPOINT=https://s3.eu-west-2.amazonaws.com
OFFSITE_S3_BUCKET=my-birdnet-backups
OFFSITE_S3_PREFIX=stations/garden # optional
OFFSITE_S3_REGION=eu-west-2 # default us-east-1
OFFSITE_S3_ACCESS_KEY=AKIA...
OFFSITE_S3_SECRET_KEY=...
OFFSITE_S3_ADDRESSING=auto # auto | virtual | path
OFFSITE_PASSPHRASE=...
OFFSITE_KEEP=8 # 0 keeps everything
OFFSITE_S3_ADDRESSING decides whether the bucket goes in the hostname (bucket.endpoint/key, which AWS requires for buckets made after September 2020) or the path (endpoint/bucket/key, which every self-hosted store speaks and some speak only). auto picks by endpoint and is right nearly always; set it explicitly if you get a 404 that mentions the bucket.
Give the station its own access key with permission to PutObject, ListBucket and DeleteObject on that prefix, and nothing else. It never reads a backup back — restoring is a thing you do from another machine, deliberately.
To an SSH server
Any host you can sftp into: a NAS, a VPS, a Pi in a different building.
OFFSITE_BACKUP=sftp
OFFSITE_SFTP_HOST=backup.example.net
OFFSITE_SFTP_PORT=22
OFFSITE_SFTP_USER=birdnet
OFFSITE_SFTP_DIR=/srv/backups/garden
OFFSITE_SFTP_IDENTITY=/home/pi/BirdNet-Behavior/ssh/id_ed25519
OFFSITE_SFTP_KNOWN_HOSTS=/home/pi/BirdNet-Behavior/ssh/known_hosts # defaults beside the key
OFFSITE_SFTP_HOST_KEY_POLICY=yes # yes | accept-new
OFFSITE_PASSPHRASE=...
Key authentication only — passwords are disabled, because a batch upload cannot answer a prompt and would hang until something killed it. The key lives where you choose; the examples put it in an ssh folder inside the station’s data directory, which is the one place the service is allowed to read and write (/home/pi/BirdNet-Behavior here: the data directory of the account the bare-metal service runs as — substitute yours; in Docker it is /data). There is no birdnet user on a bare-metal station, so run the commands as the service’s account (the OFFSITE_SFTP_USER above is the user on the backup server). Generate a key for the station and authorise it on the server:
SVC=$(systemctl show -p User --value birdnet-behavior) # bare metal: the account the service runs as
DATA=$(getent passwd "$SVC" | cut -d: -f6)/BirdNet-Behavior # its data directory
sudo -u "$SVC" mkdir -p "$DATA/ssh"
sudo -u "$SVC" ssh-keygen -t ed25519 -f "$DATA/ssh/id_ed25519" -N ''
sudo -u "$SVC" ssh-copy-id -i "$DATA/ssh/id_ed25519.pub" birdnet@backup.example.net
# Docker: docker compose exec birdnet sh -c 'mkdir -p /data/ssh && ssh-keygen -t ed25519 -f /data/ssh/id_ed25519 -N ""'
Then record the server’s host key, after checking the fingerprint against the server itself:
ssh-keyscan -p 22 backup.example.net | sudo -u "$SVC" tee "$DATA/ssh/known_hosts"
OFFSITE_SFTP_HOST_KEY_POLICY has no “off”. Host key checking is what makes the upload go to your server rather than to whoever answers, and there is no setting that disables it. Use accept-new for the first connection on a network you control, then set it back to yes so a changed key is refused.
How often
BACKUP_SCHEDULE is weekly (the default) or daily. Daily means losing at most a day rather than a week; it also means seven times the offsite upload, which is why weekly stays the default rather than changing an existing station’s bandwidth underneath it.
It schedules the backup only. The space reclaim — checkpointing the write-ahead log and returning free pages to the filesystem — is a separate job and stays weekly whatever you set here. Asking for a daily backup is not asking to rewrite the database file every day, and on an SD card that is write endurance spent for space nobody needed back.
Both are listed at GET /api/v2/system/jobs, with when each last ran and whether it is due.
Over rsync, when the link is bad
OFFSITE_BACKUP=rsync sends the same backup to the same SSH server, with the same OFFSITE_SFTP_* settings above — only the program that moves the bytes changes. Switching is one word:
OFFSITE_BACKUP=rsync
OFFSITE_RSYNC_BWLIMIT=512 # KiB/s; 0 or unset for no limit
Use it if your uplink drops. rsync keeps what already arrived, so a transfer that dies at 90 % of a 1.3 GB backup continues from there next time. Over SFTP the same backup starts again from zero, and on a link bad enough to matter it may never finish at all. OFFSITE_RSYNC_BWLIMIT is the other reason: it stops a backup saturating a shared or metered connection for an hour.
It is not faster because it is “incremental”. rsync’s famous trick is sending only the blocks that changed, and that is worth nothing here. Your backups are encrypted before they leave, under a fresh random salt each time, so consecutive backups share no blocks at all — not even when the database has not changed. Measured on a 1 MiB file: 1496 of 1497 blocks reusable in plaintext, 0 of 1498 once encrypted. rsync sends the whole file every run, exactly as SFTP does. The resume and the speed limit are the reasons to use it.
It needs both rsync and sftp installed. rsync moves the bytes; sftp creates the directory, lists what is there and deletes what retention drops — rsync has no command that removes one named remote file, and its nearest equivalent deletes everything a filter does not mention, which is not a risk worth taking with your only offsite copies.
Retention
OFFSITE_KEEP is how many backups stay at the destination; the oldest go when a new one arrives. 0 keeps everything.
Retention only removes files named like the ones this station writes — birds.db.backup.<timestamp>.bnb — directly in its own prefix or directory. Anything else there is left alone, and so is anything in a folder below it, so you can share a bucket without losing the other things in it. Give every station its own prefix: two stations writing to the same prefix produce identical names, and each would prune the other’s backups as its own.
Checking it works
birdnet-behavior --doctor
reports the destination, how many backups it will keep, and — for SSH — whether the key exists, whether its permissions are ones OpenSSH will accept, and whether the host is known. It makes no connection: --doctor runs on every start, and a diagnostic that dials a remote host fails whenever the uplink is down.
Uploads are logged at info on success and warn on failure, with the destination named. A failed upload never affects the local backup or the space reclaim that follows it.
Restoring one
Download the .bnb file from your bucket or server, then:
birdnet-behavior --decrypt-backup downloaded.bnb --out birds.db
It will ask for the passphrase. The result is an ordinary SQLite database — put it in place of birds.db with the service stopped, as with any snapshot.
Danger zone
Two destructive actions live in a clearly marked danger zone, each gated behind an explicit confirmation:
- Clear all detections — empties the detections and notification tables; settings are kept.
- Clear extracted audio — deletes the saved WAV clips; the detection records stay.
There is no undo. Take a full backup first.
Station Health
The Settings Health tab (/station) is the station’s vital-signs monitor — the operator’s “is it working?” screen, and the public, login-free heir to the old /system page (which now permanently redirects here).
- Status banner — one line that stays green while nothing needs attention, and flips to amber naming the problem when storage runs low, the database integrity check fails, no audio sources are configured, or uploads are backed up.
- Audio sources — a per-source panel fed live by the capture supervisor: a state chip (Live · Stalled · Backing off · Paused), a rolling 24-hour uptime strip, how long since audio last arrived, today’s detections for the source and the current retry/backoff line. When the web server runs without the capture supervisor (
--web-only, or tooling) it falls back to an activity view — how many detections each source produced today and how recently — and never fakes a live/stalled chip. - Vitals — CPU, memory, temperature (with a graceful “no sensor” state where a probe isn’t available) and disk, each with a meter. The disk figure follows
df’s “used of reachable space”, so reserved blocks or a container quota don’t understate it. - Microphone health — the station’s own background noise floor per source over the last 7 days, and how far it has moved against that source’s own 30-day average. This is the one signal that separates a season going quiet from a microphone going deaf: a failing capsule keeps its process alive and its status green, and shows up only as fewer detections — exactly like autumn. Ambient background does not stop when the birds do, so a large, sustained drop here, with nothing else changed, points at the equipment. The panel is absent until the station has sampled something (if the history cannot be read, the page says it could not load it rather than staying absent), and says “building a baseline” rather than reporting a change it has nothing to compare against. No threshold is applied and no alert is sent: a noise floor moves for weather, season, a road and leaf-out, and a number picked without a season of real recordings behind it would fire on all of them. Same figures are exported as
birdnet_noise_floor_dbfsandbirdnet_noise_floor_drift_dbfor anyone who wants to draw their own line. - Pipeline — the last detection (the plain-English answer to “is it actually working right now?” — every other gauge can read healthy while the station records silence, but a fresh detection proves the whole chain from microphone to database is alive), queued uploads (shown only when a network outage backs them up), the service uptime, total detections, and the species filter: whether the occurrence filter is running and how many species it currently admits. A filter admitting zero species means nothing the station hears can be recorded, and the status banner says so.
- Diagnostics — a short checklist (audio sources · disk headroom · database integrity) with a link to the station’s full
doctorreport at/admin/doctor: the same checksbirdnet-behavior --doctorruns — audio device listing, model, clock, TLS, database, offsite, disk — run on request and read-only, with Download as JSON (/admin/doctor.json, what--doctor-jsonprints) and Download a support bundle (/admin/support-bundle, the same redacted archive--support-bundlewrites) beside it. Both are behind the admin login, so a station in a field can be diagnosed from a phone without SSH.
The detection deadman
Behind the “Last Detection” row is a watchdog that turns silence into an
alert. It measures the seconds since the most recent detection and, past a
threshold you set with --deadman-hours (env BIRDNET_DEADMAN_HOURS, config
key DEADMAN_HOURS; default 24 h, 0 disables the alert), logs a loud
warning and sends one notification per quiet episode through Apprise — with
a recovery notice when detections resume. It never cries wolf on a brand-new
station that has not detected anything yet, and a silent night is well within
the default. Raise the threshold for genuinely sparse habitats. The same
freshness value is exported as the birdnet_detection_silence_seconds
Prometheus gauge and as detection_silence_secs on /api/v2/health, so you can
alert on it from Grafana or any uptime monitor. See the
Field Deployment Runbook
for the monitoring playbook.
The built-in doctor
For a deeper, scriptable check, run the diagnostic from the CLI. It prints a one-screen report covering CPU, configuration, audio reachability, the model file, database integrity, disk space, whether a container’s data would survive the container, tool dependencies and network — each problem with a concrete suggested fix.
sudo -u "$(systemctl show -p User --value birdnet-behavior)" birdnet-behavior --doctor # bare metal
docker compose exec birdnet birdnet-behavior --doctor # Docker
(On bare metal the service runs as the account that ran the installer; there is no birdnet user, and the systemctl show call prints that account.)
Exit code: 0 = all good, 1 = warnings only, 2 = at least one error.
For monitoring (Nagios / Zabbix / a Home Assistant command sensor / a Prometheus textfile collector) the same checks are available as one line of JSON:
birdnet-behavior --doctor-json | jq .
The support bundle
birdnet-behavior --support-bundle [PATH] (or Download a support bundle at /admin/support-bundle, behind the admin login) writes one .tar.gz holding the diagnostic report (doctor.json and doctor.txt), the station’s version, a redacted copy of the configuration (config.redacted), the last 2000 journal lines, the station’s own errors.jsonl (warnings and errors that survive a reboot), and uname -a and df -h; the download from the web UI also carries recent.log, the live log view’s buffer. That is what a maintainer asks for in the first three replies to a bug report. Passwords, tokens and URL credentials are masked. The station’s LATITUDE and LONGITUDE are shown to one decimal place (about 11 km) in the configuration section, and wherever the journal printed them (BirdWeather’s start-up line, a settings change) the exact values are replaced by the same coarse form; a value that is not a number is masked. One decimal says which hemisphere, which season’s daylight and which region’s species, which is all a diagnosis needs; four decimals are a house. The bundle is written to be pasted into a public issue, but read it before you post it all the same. (Earlier builds printed the coordinates exactly.)
Before an upgrade: --migration-report
Most database migrations only add columns and indexes. A few rewrite rows that are already on disk and cannot be undone from inside the database; migration 24, which folds each detection’s chunk offset into its timestamp, is the one that has done so. --migration-report says what the next start would do to your history, before it does it:
sudo -u "$(systemctl show -p User --value birdnet-behavior)" birdnet-behavior --migration-report # bare metal
docker compose exec birdnet birdnet-behavior --migration-report # Docker
For each pending migration that rewrites rows it prints what would move, by how much, how many rows are left alone and why, and the date range involved. It opens the database read-only and changes nothing, so it is safe on a live station. If there is no database yet it says so and stops; if no pending migration rewrites rows it says that, adding that schema-only migrations may still be pending.
The rewrite itself happens on the next normal start, and just before it the database is copied to <db>.pre-migration-<version>.backup, so undoing it is a file move. The copy needs about as much free space as the database. A backup that cannot be written stops the migration rather than proceeding without one; a station whose disk cannot hold the copy can set BIRDNET_SKIP_MIGRATION_BACKUP=1 and accept that the earlier timestamps cannot be recovered (see Configuration).
The species totals: --rebuild-species-summary
The species list and the per-species charts read a maintained table of totals (species_summary) rather than counting the whole history on every page load. It is derived data. --rebuild-species-summary recomputes it from the detections and exits:
sudo -u "$(systemctl show -p User --value birdnet-behavior)" birdnet-behavior --rebuild-species-summary # bare metal
docker compose exec birdnet birdnet-behavior --rebuild-species-summary # Docker
It prints how many species-per-hour buckets it rebuilt and how many had disagreed with the detections beforehand, and the detections themselves are not touched. If the summary still disagrees after a full rebuild it exits with an error rather than reporting success, which means something is writing to detections in a way the summary’s triggers cannot see.
You should rarely need it. --doctor reports a disagreement under “Species summary” and names this command, and the running station also checks for one once a day and, when it finds drift, rebuilds the summary itself.
How much memory the analytics engine gets
DuckDB treats its memory limit as permission to use that much, so on a small board a limit that is a large share of physical RAM is a standing invitation to be OOM-killed in the middle of a dashboard query — at three in the morning, when nobody is looking.
The station sizes the pool itself. Unset, BIRDNET_DUCKDB_MEMORY_LIMIT becomes
a quarter of whatever this process may actually use: the smaller of physical
RAM and any cgroup limit it is under (the systemd unit ships MemoryMax=1G; a
container has its own). That is capped at 256 MiB and floored at 64 MiB.
- On the shipped unit that comes to exactly 256 MiB — the flat value the station used before, so nothing changes on the hardware it was written for.
- On a 512 MB board it comes to 128 MiB, instead of handing one subsystem half the machine.
- Below a 256 MiB ceiling the floor cannot be met, and analytics is refused at startup with a line in the journal saying so. The station still records, classifies and serves; it just has no behavioural dashboards. Refusing is the point: the alternative is starting something that will be killed mid-query.
Set the variable and it is used verbatim, on any machine — including one the sizing would have refused. Someone who has measured their own station knows more than this does.
Where the two numbers come from, since neither is a preference:
- A quarter is the proportion the shipped unit already implies —
MemoryMax=1Gwith a 256 MB pool — so the default hardware is unchanged by construction. Aconstassertion in the source fails the build if either number moves without the other. - 64 MiB was measured, not chosen. A sessionisation shaped like the
behavioural queries —
lagand a running sum over 1.5 million detections partitioned by species, then aggregated — ran out of memory at 8, 16 and 32 MiB and succeeded from 48 MiB up on DuckDB 1.5. 64 MiB is the next step above the smallest observed working value.
Threads. Unset, BIRDNET_DUCKDB_THREADS becomes the CPUs this process may
run on less the two the bird classifier uses, and at least one — 2 on a
Raspberry Pi 4. DuckDB’s own default is every core, whatever the process’s CPU
affinity. Measured on a 4-core x86 host with two busy loops standing in for the
classifier, two threads halved the slowest dashboard query against one and slowed
the competing load no more than one thread did (84–104 % of its idle rate,
against 85–100 %); four threads were about a fifth faster
again but cut the competing load to 62–75 % and used more memory. Set the
variable to override, for example on a station that only serves dashboards.
What is not measured is the rest of the process’s footprint on a Raspberry Pi,
which would need a Pi. So this sizes a proportion, not a budget: it makes the
analytics engine’s share scale with the machine, and does not claim to know the
remainder is enough. --doctor reports which case applied under Analytics
memory.
Metrics & logs
- Prometheus metrics are exposed at
/api/v2/metrics. - A live log viewer (
/admin/system/logs/page) streams the service log over SSE with level filtering. A connecting client is replayed the last 200 lines first, so you see what led up to now rather than only what happens next. This is the whole picture in Docker, where there is nojournalctlto fall back on. errors.jsonl, beside the database, keeps ERROR and WARN lines only, one JSON object per line, capped at 1 MB. It exists because a default Raspberry Pi OS has no/var/log/journal: the journal is volatile, so every watchdog bounce, power cut and update erases the evidence of what caused it — including the reboot you are trying to explain.--support-bundlecarries this file.
If you ran an earlier version. The log viewer streamed nothing at all. Its backing channel existed and the page connected to it, but no
tracinglayer was ever installed, so it replayed an empty backlog and then emitted keep-alives for ever. The page now shows what the station logged.
CPU profiles
When the station is busier than it should be and nothing above says why, a CPU
profile does: what every thread was doing, sampled 99 times a second. It is not
in the release binaries — build with the profiling feature
(cargo build --release --features profiling, or BUILD_FEATURES=analytics,profiling
for the Docker image) — and an administrator then asks for one:
https://<station>/admin/debug/pprof/profile?seconds=30
The browser downloads cpu-<date>.pb.gz, which go tool pprof, pprof and
Speedscope open. Add &format=folded for plain
text, one line per stack (thread;outermost;…;innermost count), which Speedscope
and flame-graph tools also read. seconds may be 1 to 120 (default 30).
Nothing samples until a profile is asked for, and one is taken at a time — a
second request meanwhile is refused with 409. While one runs the station
spends a little time in the sampler, so ask for a profile when there is a
problem to look at rather than leaving a monitor polling it. Viewer accounts are
refused. There is no heap profile: one would need a different memory allocator
in every build.
Audit log
/admin/audit records who changed what. Rows are kept for 180 days and pruned
by the maintenance loop.
Actions are dotted and hierarchical, so the page’s filter selects a family with
a prefix — auth.% for every sign-in, species.% for every filter change:
| Family | Actions |
|---|---|
auth. | login.ok, login.fail, login.throttled, logout |
account. | user.create, user.delete, password.set, session.revoke, session.revoke_others |
settings. | update |
species. | include.add, include.remove, exclude.add, exclude.remove, whitelist.add, whitelist.remove, threshold.set, threshold.delete, detections.delete, clips.delete |
audio. | source.create, source.update, source.delete, source.restart, source.listen_default |
rule. | create, delete, toggle, import |
detection. | delete, lock, unlock, review, relabel, comment.add, comment.delete |
quarantine. | approve, reject, delete |
image. | blacklist.add, blacklist.remove |
notification. | prune |
data. | detections.clear, recordings.clear, database.restore, backup.run, backup.delete |
system. | restart, update.apply |
today. | layout.change (the block and the move, e.g. nudge hide) |
The detection. rows are written for the same change whichever way it was made, and say which way: via=api for the write API, otherwise via= the page (today, recordings, search, review, detection). Search’s bulk bar records one row per detection it changed. A change that did not happen (a row already gone, a write the database refused) records nothing; a refused write is in the log at warn.
species.detections.delete and species.clips.delete are the bulk actions on
Species storage. Each records how many rows it
changed and how many locked detections it deliberately kept, so the log
explains a count that would otherwise look wrong.
A failed sign-in records the submitted username and no actor — “someone tried
to sign in as admin sixty times last night” is the thing worth knowing, and a
username that does not exist is as interesting as one that does.
login.throttled is an attempt the station refused without checking the
password: after five failures from one address inside fifteen minutes, that
address is answered 429 until its oldest failure is a quarter-hour old, and
each refused attempt is recorded here too. A successful sign-in clears the
address; a restart forgives everything.
Values are never recorded. A settings save lists the names of the keys that
changed and nothing else. rtsp_url is why: an RTSP URL routinely carries
user:pass@ in its authority, and this page renders its rows verbatim. A save
that changed nothing writes no row at all, because the settings form posts every
field on every submission and recording each one would turn the log into a click
counter.
Destructive actions — clearing detections, restoring a database, restarting, applying an update — are recorded before the work starts, not after. If the process does not survive the operation there is no “after” to record from, and a station whose history vanished with nothing in the audit log is indistinguishable from one that was never used.
If you ran an earlier version. This page was permanently empty. The table, the store, the page and the 180-day pruner all existed, and the one function that writes a row had no callers outside its own tests — so on a shared station the page did not read as “the log is broken”, it read as “nothing happened”.
Tuning Detection Accuracy
Out of the box BirdNet-Behavior is tuned to be sensible. But every station is different — a noisy roadside, a quiet woodland, a single mimic that fools the model — and you can dial it in. This page explains what each knob does and which way to turn it.
The golden rule: change one thing at a time, then watch the Quality dashboard and your Today log for a day before changing the next.
The two failure modes
| Symptom | What’s happening | Turn this way |
|---|---|---|
| Too many wrong birds (false positives) | The model is logging low-quality guesses | Raise confidence / (V2.4 only) sensitivity up / add a per-species threshold |
| Missing birds you can hear (false negatives) | The bar is too high, or the signal is poor | Lower confidence / (V2.4 only) sensitivity down / check mic levels first |
If you’re getting both at once, it’s almost always an audio problem (wind, clipping, a bad mic position) — fix that first on the Audio page before touching detection settings.
The knobs, in the order you should reach for them
1. Confidence threshold
The minimum score (0–1) a detection must clear to be logged. This is your main dial.
- Default 0.70, which is BirdNET-Pi’s own (it was 0.75 up to v0.17.1). Raise toward 0.8–0.85 to aggressively cut false positives (you’ll lose some faint genuine calls).
- Lower toward 0.5–0.6 only if you’re confident your audio is clean and you want maximum recall.
- Set it at
/admin/settings → Detection.
2. Per-species thresholds
One noisy species shouldn’t force you to raise the global bar for everyone. Override the threshold for individual species instead:
- A local mockingbird or starling logging junk? Give just that species a high threshold (e.g. 0.9).
- A rare bird you don’t want to miss? You can lower its threshold — but pair that with the quarantine queue (below) so each one still gets a human glance.
The Quality dashboard lists your lowest-confidence species — those are the best candidates for a per-species override.
3. Sensitivity
The BirdNET sensitivity parameter (0.5–1.5; also SENSITIVITY in birdnet.conf), with BirdNET-Pi’s meaning. It reshapes the confidence curve of a V2.4 model, whose raw scores are passed through a sigmoid with slope clamp(2 − sensitivity, 0.5, 1.5):
- Higher (→1.5) is a flatter curve. Every score moves toward 0.5, so at any confidence threshold above 0.5 fewer calls clear the bar, and the confidence you see for a strong call is lower.
- Lower (→0.5) is a steeper curve: strong calls score higher and more of them clear the bar.
- The default, 1.25, is a slope of 0.75: the same figure BirdNET-Pi gives a station with its defaults. A magpie whose raw score is 3.12 reads 91.2 % here and there.
- Most stations leave this at the default and tune the confidence threshold instead. Reach for sensitivity only when the threshold alone can’t find a good balance.
- It only affects BirdNET V2.4 models; the BirdNET+ V3.0 preview reports calibrated probabilities and ignores it.
- birdnet-go’s setting of the same name is the slope itself (its default, 1.0, is a plain sigmoid). To carry a birdnet-go value across, enter
2 − value.
4. Species-frequency (SF) filter
Uses your location and the week of the year to drop birds that shouldn’t plausibly be present (no penguins in your back garden in July). The SF_THRESH (default 0.03) sets how aggressive that prior is.
It needs your coordinates and an occurrence model that matches the classifier:
- BirdNET V2.4 (the default for a new install) pairs with the range filter v2,
birdnet-v24-range-v2.onnx(about 30 MB). It is indexed like the V2.4 labels, so it needs no label file of its own. - BirdNET+ V3.0 pairs with the BirdNET geomodel and that model’s own label file (about 14 MB together).
The two pairs are never mixed. The installer and the Docker entrypoint fetch the right one and wire it up, so on a current install the only thing you supply is the location. The rest of this section is mostly about the geomodel pair, because that is the one with a second label file to align.
Check it is actually on — SF_THRESH does nothing by itself:
$ birdnet-behavior --doctor | grep occurrence
[ PASS ] Species occurrence filter - active - .../BirdNET+_Geomodel_V3.0.2_Global_12K_FP32.onnx ...
(That line is a V3.0 station’s; a V2.4 station’s names the range filter and says it is indexed against the classifier’s labels.) A WARN there names whichever of the missing pieces it needs: the model, or the coordinates. The geomodel download is deliberately non-fatal: a station without it still detects, it just stops filtering by location, and re-running install.sh repair picks it up later.
| Setting | birdnet.conf | Environment | Flag |
|---|---|---|---|
| Occurrence model (the range filter, or the geomodel) | METADATA_MODEL_PATH | BIRDNET_METADATA_MODEL | --metadata-model |
| Its label file (the geomodel only) | METADATA_LABELS_PATH | BIRDNET_METADATA_LABELS | --metadata-labels |
| Extra name aliases | SPECIES_ALIASES_PATH | BIRDNET_SPECIES_ALIASES | --species-aliases |
The model takes (latitude, longitude, week) and returns one occurrence probability per species. It does not score the same species list as the classifier — the geomodel covers 12 012 species across birds, mammals, insects, amphibians and reptiles, where the V3.0 Global 11K classifier emits 11 560 — so the label file is what maps one list onto the other.
Omit the label file only for a metadata model indexed identically to the classifier (a matched BirdNET pair, e.g. a V2.4 MData model beside V2.4 labels). The station checks that at startup and refuses a mismatched model rather than reading one list’s index into the other, which would report birds under other birds’ names with full confidence.
When the two files disagree about a name
The two label files were frozen at different points in a moving taxonomy, so they do not always spell a species the same way. On the pinned pair, 1 679 of the geomodel’s 12 012 rows have no exact scientific-name counterpart in the classifier — and 62 of those are the same bird under a reclassified genus. The geomodel says Leuconotopicus villosus where the classifier says Dryobates villosus; both mean Hairy Woodpecker.
Matching on the scientific name alone, those 62 species are permanently undetectable while the occurrence filter is on: the geomodel’s opinion about them never reaches the passing set under a name a detection can carry. Forty-one of them are birds, among them Hairy Woodpecker, Red-cockaded Woodpecker, White-headed Woodpecker and Evening Grosbeak.
So a name that does not match exactly is tried a second way: the common name, compared on its letters alone so Fruit-Dove and Fruit Dove are one name, together with the specific epithet, which must agree exactly or differ only by its Latin gender ending (gymnocerca / gymnocercus). A common name that names two species in either file identifies neither and is not used, and a species the scientific-name pass already matched is never re-matched by the looser rule.
The epithet is what makes that rule safe rather than merely generous. The classifier’s own label file calls Lama glama — the llama — “Guanaco”, and calls Scapteriscus borellii “Southern Mole Cricket”, which the geomodel uses for a different genus entirely. Both would match on the common name alone; neither matches once the epithet has to agree.
--doctor reports the result, in both directions:
$ birdnet-behavior --doctor | grep occurrence
[ PASS ] Species occurrence filter - active - ... ; 10395 of the metadata model's
12012 species map onto the classifier (62 by common name + epithet, 0 by an operator
alias, e.g. Drymomantis fallax = Litoria fallax), 1617 do not, and 1165 of the
classifier's 11560 species are reached by no metadata row and so cannot pass
while it runs
Read those two residues differently. A metadata species with no classifier counterpart is almost always right — the geomodel simply knows more species than the classifier can emit. A classifier species that no metadata row resolves to is one this station cannot report at all while the filter is on, because the geomodel has no opinion to offer about it.
Your own aliases
For a pair the automatic rules cannot connect — different genus and different common name — point SPECIES_ALIASES_PATH (BIRDNET_SPECIES_ALIASES, --species-aliases) at a tab-separated file:
# legacy name (as the geomodel spells it) the classifier's name
Streptopelia senegalensis Spilopelia senegalensis
Blank lines and # comments are skipped, and a malformed line is skipped rather than taken as fatal: a typo here must not take the occurrence filter off a running station. The count loaded and the number of lines skipped are logged at startup.
No alias table ships with the station. The obvious candidate, OpenFauna’s aliases.json, is CC BY-SA 4.0 where this project is CC BY-NC-SA 4.0, and ShareAlike does not permit adding the NonCommercial restriction. It would also not help: applied to the pinned model pair, all 237 of its entries recover none of the 1 679 unmatched rows, because the reclassifications it records are ones both of our label files already agree on. If you want it anyway, it is yours to install under its own licence.
Once it is running:
- Make sure your latitude/longitude are correct first — the filter is only as good as your coordinates.
- Raise
SF_THRESHto be stricter about out-of-range species; lower it (or 0) to let everything through (useful if you want to catch vagrants).
5. Rain, wind and other broadband noise
Nothing in the station drops rainy or windy audio before it reaches the model. Weather shows up as false positives, and the levers are the ones above: a modestly higher confidence threshold, a per-species threshold for the species it keeps producing, and the noise filter in §7 for a recurring non-bird sound. The cheapest fix is at the microphone: a windshield and shelter from direct rain do more than any setting. The Quality dashboard lists the species whose confidence is lowest, which is where weather-driven false positives collect.
6. Rare-bird quarantine
Rather than choosing between “log everything” and “miss the rarities,” send borderline rare birds to the quarantine queue for a quick human approve/reject. This lets you run a lower threshold for rare species without polluting your life list.
7. Barking dogs and other non-birds
BirdNET’s label set is not birds only — it carries Dog, Siren, Engine,
Fireworks, Power tools, Gun, Environmental and Noise, because the
training data contains them. A dog barking near the microphone is broadband
enough that the classifier does not answer Dog and stop: it also produces
confident-looking scores for whatever species the bark most resembles. Because
the barking is regular — same dog, same garden, every evening — the phantom
accumulates until it looks like a resident.
BIRDNET_NOISE_THRESHOLD=0.6 # 0 = off; typical 0.5–0.8
BIRDNET_NOISE_CLASSES=Dog # unset = Dog; empty = watch nothing
When a watched class scores at or above the threshold, every detection in that
three-second chunk is discarded. Only that chunk — a bark is a few hundred
milliseconds, and the chunks overlap, so a bark on a boundary is caught in both.
Beside a road or a fire station, add Siren and Engine. Do not add
Noise or Environmental: they score highly on ordinary quiet recordings and
will suppress most of the night.
That handles the barks. It does not handle the gaps between them, and for a
dog that barks for a minute the gaps are where the damage is: no Dog above
threshold, nothing for the filter to fire on, and the classifier — still
hearing the tail of the bark and the room — producing the same phantom species
it produced during it. The filter silences the barks and lets the gaps through.
NOISE_REMEMBER_SECS=45 # 0 = off, the default
After a chunk is suppressed, the species that were in that chunk stay suppressed for this many seconds. Only those species: a blanket window would be a mute button, and a dog barking through the dawn chorus would erase the chorus. A blackbird singing in the same gap is still recorded.
It is off by default because it does remove real detections whenever a real bird happens to be the species a dog resembles. Reach for it when a regular dog is filling your log with one particular phantom; 30–60 seconds is a sensible starting point.
It reaches to the end of the recording being analysed and no further. Carrying it across segments would mean state on a filter that every audio source shares, and a bark on the garden microphone must not silence a species on the pond one.
8. One song, one detection
A 15-second recording is five 3-second chunks, so a bird singing throughout is recorded five times. Every count in the application is a row count — daily totals, the activity heat map, the dawn-chorus curve — so a species that sings in long phrases outscores one that calls in short bursts for no reason but phrasing.
BIRDNET_DUPLICATE_INTERVAL_SECS=30 # 0 = off
Each species is then admitted at most once per interval on each source (the microphone, or each RTSP camera); the first chunk wins, because that is when the bird started singing. Two cameras that hear a bird 20 seconds apart each record it: they are two places. A station with one source behaves as if the interval were per species. Off by default, since turning it on changes how many rows your station records and puts a visible step in every chart on the day you do it.
One species louder than the rest. A tawny owl calling all night will drown
the log long before a blackbird does. Settings → Capture → Which birds count → Per-Species
Minimum Interval gives a single species its own interval in seconds (1 to
86400), which replaces the station-wide one for that species and applies
even when the station-wide one is off — the same behaviour as birdnet-go’s
per-species interval. A repeat inside it is dropped like any other duplicate:
no row, no clip, no notification, not counted. The window runs from the last
recorded detection, a detection quarantined by a threshold does not start
one, and the setting is picked up within about thirty seconds, no restart. It
is separate from the notification cooldowns, which only ever limit messages and
never rows.
9. Day birds at night
A blue tit “detected” at 02:30 is almost always the model hearing something else. A blanket night filter would be worse than the problem — owls, nightjars, rails and bitterns call at night on purpose and are the detections most worth having — so the filter asks who, not just when.
BIRDNET_NIGHT_FILTER=1
BIRDNET_NIGHT_MARGIN_MINS=60
BIRDNET_NIGHT_EXTRA_NOCTURNAL=Catharus,Vireo
Species in a genus known to call at night are exempt; everything else detected between sunset + margin and sunrise − margin is sent to quarantine, never dropped, because the taxonomy is genus-level and cannot be complete. It needs your station coordinates, and it fails open: no coordinates, an unreadable timestamp or a polar summer all mean “keep everything”.
If you record nocturnal flight calls, leave this off — migrating thrushes
and warblers calling overhead are exactly what it would quarantine — or name
those genera in BIRDNET_NIGHT_EXTRA_NOCTURNAL.
10. Asking for a second opinion
A real bird sings across more than one analysis window. A classifier artefact — a car door, a squeak of the mount, a fragment of speech — usually fires in exactly one. The repeat-confirmation filter uses that: a species is recorded only if enough of the windows around it heard the same thing.
BIRDNET_OVERLAP=2.0 # required — see below
BIRDNET_CONFIRMATION_LEVEL=lenient # off | lenient | moderate | balanced | strict
Each level names the fraction of the windows within six seconds that must carry the species — 20%, 30%, 50%, 70% — rounded up, and never fewer than the detection itself.
It does nothing without BIRDNET_OVERLAP. With no overlap, six seconds is
two 3-second windows, and 20% of two rounds up to one — which every detection
already satisfies. How much overlap each level needs:
| Level | Fraction | Windows it looks at | Minimum useful OVERLAP |
|---|---|---|---|
lenient | 20% | 6 s | 2.0 |
moderate | 30% | 6 s | 1.5 |
balanced | 50% | 6 s | any (already 2 of 3) |
strict | 70% | 6 s | any (already 3 of 3) |
--doctor reports which of these you are in, and the daemon logs a warning at
startup when the level you set cannot reject anything at your overlap — so a
setting that does nothing tells you, rather than looking like the filter
working.
Overlap is not free: OVERLAP=2.0 triples the number of windows and therefore
the inference work per recording. On a Pi 4 or smaller, watch
birdnet_files_analysed_total against the segment rate (about 5 760 a day per
source at 15 s) after turning it on — a rate that falls behind means the board
is not keeping up.
Start at lenient. strict on a station recording distant or intermittent
singers will lose real detections — a bird that calls once and moves on is
indistinguishable, to this filter, from an artefact.
Letting the station tune itself
Two places on the web UI turn your own review history into advice. Both only ever suggest; nothing changes until you press the button.
Suggested thresholds (Settings → Capture → Species). For each species you have both confirmed and rejected detections of, the station works out the threshold that best separates the two, and shows what it would have cost (confirmations lost) and caught (rejections stopped) against the reviews it came from. Suggestions that separate nothing are not shown — if your reviews and the model’s confidence disagree at random, there is no threshold worth applying.
The more detections you review, the better this gets. Reviewing a mix of good and bad detections for one species is worth far more than reviewing many of either alone: with only confirmations the best answer is “admit everything”, which is not a threshold.
Species that may not be birds (Settings → Data). Flags species by the shape of their detections rather than by name: every review rejected, never detected confidently, confidence that never varies, many detections on very few days. Two of those signals have to agree before a species is listed, and it needs at least 10 detections first, so a genuine scarce visitor is not flagged on the day it arrives. The Exclude button adds it to the ordinary species exclusion list, where you can undo it.
If you have configured eBird, a flagged species that somebody else reported near you recently is marked as such, and the Exclude confirmation says so — somebody standing near your microphone wrote that bird down, which is about the strongest argument there is against excluding it. The reverse does not hold and the station never pretends it does: eBird saying nothing about a species means nobody nearby submitted a checklist with it, which is the normal state of affairs anywhere without many birdwatchers. An unmarked row is not a row eBird disagrees with.
A recommended starting recipe
- Set your location accurately.
- Get mic levels right (peaks near −6 dB).
- Run the defaults for a few days and read the Quality dashboard.
- If false positives dominate: nudge the confidence threshold up by 0.05, and use the noise filter if a particular non-bird sound (a dog, a siren) is the cause.
- Add per-species overrides for the one or two species generating the most noise.
- Turn on quarantine for rare birds so you can keep recall high without losing trust in the log.
- Review a mixture of good and bad detections for a week, then check the suggested thresholds on the Species page and the suspect species list under Settings → Data — by then both have something to say.
Everything here is reversible and stored in the database, so experiment freely — and remember to change just one knob at a time.
Same audio, same detections
If you ran BirdNET-Pi for years and trust what it told you, you should be able to
move here and see the same birds for the same sounds. This page says what that
takes, what was measured, and where the three projects differ whatever you do.
BirdNET-Pi here means Nachtzuster’s fork
at 88985a3; birdnet-go means tphakala/birdnet-go
at f4f019a.
Same model does not mean same detections, even between BirdNET-Pi and birdnet-go. They run the same BirdNET V2.4 network and differ in how a score becomes a stored row (the table below). This project is held to BirdNET-Pi, because that is where its users and its settings file come from.
What you get out of the box
A new install runs BirdNET V2.4 with its range (occurrence) filter v2, and the three settings that decide what becomes a stored row default to BirdNET-Pi’s own values:
| BirdNET-Pi | A new install here | |
|---|---|---|
| Classifier | BirdNET V2.4 | BirdNET V2.4 (birdnet-v24.onnx, FP32 where BirdNET-Pi runs FP16) |
| Occurrence filter | MData v1 by default | MData v2 (birdnet-v24-range-v2.onnx); v1 is not hosted as ONNX |
CONFIDENCE | 0.70 | 0.70 (it was 0.75 up to v0.17.1) |
SENSITIVITY | 1.25 | 1.25, with BirdNET-Pi’s meaning |
| Human-sound rule | on: a human class in the top 10 masks the window | on: PRIVACY_RANK 10, the same rule |
BirdNET+ V3.0 preview, which releases up to v0.17.1 installed for everyone, is a different network: it listens to 4.5 s windows (V2.4: 3 s), reports calibrated probabilities (V2.4: raw scores that SENSITIVITY reshapes), and names about 11 500 species (V2.4: 6 522). Its detections are not BirdNET-Pi’s and are not meant to be. Choose it with --classifier birdnet-v3 (see Installation) if you want the newer model. Two defaults here are tuned to V2.4 and say less on V3.0: SENSITIVITY is ignored (V3.0 reports calibrated probabilities), and the human-sound rule hardly fires (measured below: 0 of 31 windows of read speech).
Two things to know if you are upgrading rather than installing:
- The classifier is not changed. A station that has a configuration keeps the classifier that configuration names, and a Docker volume that holds the V3.0 model keeps V3.0.
- Two defaults move with the binary. A station that never set
CONFIDENCE(not inbirdnet.conf, not saved on the Settings page or in the onboarding wizard) now records at 0.70, not 0.75. A station that sets neitherPRIVACY_RANKnorPRIVACY_THRESHOLDnow has the human-sound rule on, where before nothing was suppressed: a window in which a human class ranks in the model’s top 10 classes, and the windows either side, are not recorded. SetPRIVACY_RANK=0to switch it off.
Moving an existing station to BirdNET-Pi’s results
Install the two files (Docker: docker compose exec birdnet birdnet-behavior --install-model …; on bare metal name the model folder, as Installing one shows):
birdnet-behavior --install-model birdnet-v24 # classifier + labels
birdnet-behavior --install-model birdnet-v24-range-v2 # species-occurrence filter
Then, in birdnet.conf (or the BIRDNET_* variables), and restart. <model dir> is /data/model in Docker and <data dir>/models on bare metal:
MODEL_PATH=<model dir>/birdnet-v24.onnx
LABELS_PATH=<model dir>/birdnet-v24_labels.txt
METADATA_MODEL_PATH=<model dir>/birdnet-v24-range-v2.onnx
LATITUDE=… # needed by the range filter, as in BirdNET-Pi
LONGITUDE=…
CONFIDENCE=0.70 # BirdNET-Pi's default; this project's from the first release after v0.17.1
SENSITIVITY=1.25 # the same number, with the same meaning, as BirdNET-Pi's
SF_THRESH=0.03 # the same
OVERLAP=0.0 # the same
PRIVACY_RANK=10 # the same rule; see below
With those, a V2.4 station stores BirdNET-Pi’s species at BirdNET-Pi’s confidences for the same audio, to the tolerances below. The last four lines are the defaults; they are written out so that a value you changed earlier is visibly put back. The one setting that does not carry across unchanged is BirdNET-Pi’s PRIVACY_THRESHOLD.
Migrating a PRIVACY_THRESHOLD. BirdNET-Pi reads the key as a percentage of the species list: a window is human if a human class ranks within the top max(10, 60 × value) classes. Here PRIVACY_THRESHOLD is the model’s own confidence for a human sound, and the rank rule has a key of its own. For a BirdNET-Pi value p, set PRIVACY_RANK=max(10, 60 × p) and PRIVACY_THRESHOLD=0 (so p=0 gives 10, p=1 gives 60 and p=10 gives 600). A BirdNET-Pi 1 left in PRIVACY_THRESHOLD is in range here, validates clean, and then masks nothing short of certainty; --doctor warns about any value above 0.5 and says this. PRIVACY_RANK accepts 0 (off) to 12 000, as --privacy-rank and BIRDNET_PRIVACY_RANK as well as the config key. It is the Privacy Rank field in the Detection section of the Settings page (/admin/settings; blank = the default, 10), and it can still be set in the file, the environment or on the command line. Either rule suppresses a window: PRIVACY_THRESHOLD (0 = off, typically 0.01–0.03 when used) is a second, independent rule on top of the rank rule.
The commit that added PRIVACY_RANK (6888399) reports its decision agreeing window for window with BirdNET-Pi’s own masks on 13 of the 14 parity clips with the real V2.4 model, including all four speech windows; the fourteenth is the synthetic clip’s digital-silence tail, which is already listed below as disagreeing between every pair of model builds. That comparison is tools/parity/compare_rust.py (its privacy column); it was re-run for this page and gives the same 13 of 14 clips (52 of 54 windows).
What was measured
Everything below was produced by a command in tools/parity and re-run for this page; tools/parity/README.md lists the commands. The reference is BirdNET-Pi’s own, unmodified code (88985a3) running its V2.4 FP16 TFLite model. Two sets of recordings went through it and through this project:
- The first set, 14 clips (54 analysis windows): a magpie, a binaural dawn chorus in several formats and lengths, a tawny owl, a crow, a spoken-word recording, synthetic silence and noise.
- The second set, 106 clips (900 windows, 30 s each except 20 five-second clips): 49 geotagged field recordings picked from the archive.org radio aporee collection by title keyword only (dawn chorus, rain, wind, night, frogs and insects, crowds and speech, traffic, water, waterbirds, quiet), 29 more picked the same way by bird name, 20 sound-effect classes from ESC-50 (birds, other animals, weather, machines, human sounds), 4 LibriVox speech excerpts and 4 generated clips (pink noise, a 1 kHz tone, a clipped square wave, digital silence). 40 of the 106 are 44.1 kHz and had to be resampled. BirdNET-Pi stores 79 rows on them, on 18 clips.
Nothing in either set was chosen for what BirdNET hears in it; the selection is in search_extended.py. The audio is not in the repository; fetch_fixtures.py rebuilds every clip and checks its sha256.
What a station stores: the real service, end to end
A fresh-install configuration (the file installer/lib/62-config-file.sh writes, with the station’s coordinates, and the two model files --install-model fetched and verified) in an empty directory, the real binary started the way the systemd unit starts it, each clip dropped into the watch directory under a YYYY-MM-DD-birdnet-HH:MM:SS.wav name, and the rows read back from SQLite:
| BirdNET-Pi stores | This project stores | Same row (species and window) | |
|---|---|---|---|
| First set (14 clips) | 12 | 12 | 12 |
| Second set (106 clips) | 79 | 80 | 79 |
The one extra row is a robin in a 44.1 kHz recording: BirdNET-Pi’s score for that window is 0.6931 (below 0.7), this project’s 0.7025. Running BirdNET-Pi’s FP16 model on this project’s own input gives 0.6926 and this project’s FP32 model on BirdNET-Pi’s input gives 0.7024: it is the FP16 weights, not the pipeline, and a window that close to the bar can go either way. On the 91 rows both stored, the confidences differ by at most 0.0067 (the 22.05 kHz clip; 0.0040 otherwise), mean 0.0007. The species name, the common name, the date and time (file time plus the window’s start) and the clip’s file name come out the same. With OVERLAP=1.0 and 2.0 the magpie recording stores 5 and 11 rows, the same windows as BirdNET-Pi (confidences within 0.0007); overlapping windows are not merged here, as they are not there.
Before the resampler fix described below, the first set stored 11 of those 12: a borderline great tit in the 44.1 kHz clip (BirdNET-Pi 0.7113, here 0.6564) was missing.
What is stored differently, whatever the audio. The Confidence column holds the score as computed (0.9122954607); BirdNET-Pi rounds to four places (0.912, in its own test). The Week column holds this project’s 48-week-year week (7 where BirdNET-Pi writes the ISO week 8, 18 where it writes 20, 12 where it writes 13), because that is the week the range filter is asked about; it is not an ISO week, so a query that groups imported and native rows by week mixes two calendars.
Stage by stage
| Stage | Result |
|---|---|
| Decoding and mixing to mono, 48 kHz recordings | Bit-identical to BirdNET-Pi’s samples: first three windows of each of 76 clips (10 + 66; 15 mono and 183 stereo windows in the second set), largest difference 0.0 |
Windows, default OVERLAP=0 | Same count and same starts on all 13 clips of the first set that BirdNET-Pi cuts and all 106 of the second; against BirdNET-Pi’s own splitSignal, same count for every recording length from 1.5 s to 40 s |
| Windows, other overlaps | 9 097 of 9 145 (overlap, length) combinations give the same count as splitSignal; 48 give one window fewer here (see below) |
| Resampled recordings (44.1 kHz) | Input to the model within 0.16 % RMS of BirdNET-Pi’s (median over 100 windows from 40 clips), at most 2.8 % (a recording of crickets, nearly all of its energy near the filters’ edge); before the fix it was 28 % on the first set’s clip. See below |
| Raw scores, all labels both put in the top 10 | 8 564 pairs in the 870 ordinary windows: gap in raw score (logit) median 0.009, 90th percentile 0.039, 99th percentile 0.34 (clip-bootstrap 95 % interval 0.12 to 0.68), largest 1.34; in the 16 zero-padded windows (148 pairs) median 0.08, largest 0.78; in the 14 digital-silence windows (28 pairs) at most 0.37 |
| Top label of a window | The same in 874 of 900 windows (97.1 %, clip-bootstrap 95 % interval 94.4 to 98.9; 859 of 870 ordinary windows, 98.7 %). The 26 others are windows where nothing scores above 0.29 on either side and two labels are close |
The decision that stores a row (CONFIDENCE 0.7), before the occurrence filter | The same set of labels at or above 0.7 in 899 of 900 windows. Of the 115 windows where either side has a label at 0.7 or more, 114 agree (99.1 %; Wilson 95 % interval 95.2 to 99.8); 114 (window, label) pairs reach 0.7 on at least one side in ordinary windows and 1 of them does so on one side only (the robin above). At 0.5: 187 of 189 windows; at 0.3: 305 of 314 |
Human-sound mask (PRIVACY_RANK 10 against BirdNET-Pi’s PRIVACY_THRESHOLD 0) | The same windows masked in 886 of 900 (98.4 %; 866 of 870 ordinary windows). BirdNET-Pi masks 292 of the 900 (32 %): this rule is far from rare in noisy recordings. The 14 differences: 10 are one digital-silence clip (below), 4 are windows where the best human class sits at rank 8 against 10, 34 against 15, 10 against 4, 70 against 44 |
| Species-occurrence filter, the model | This project’s ONNX conversion of the v2 model returns exactly BirdNET-Pi’s own TFLite v2 list for all 265 (place, week 1 to 53) questions, and for eight stations on five continents on every day of 2024, 2026 and 2028 (8 776 station-days; sets of label indices compared) |
| Species lists | 21 of 27 scenarios run through BirdNET-Pi’s run_analysis keep the same species; the 6 that differ are listed below |
Every remaining difference, with its cause
The decomposition below runs BirdNET-Pi’s FP16 TFLite model and this project’s FP32 ONNX model on the same chunk, then on each side’s own chunk (tools/parity scratch script; the numbers are logits).
- Digital silence and zero-padded tails: inherent to the two builds of the model. On a recording’s zero-padded last window the two get identical input (difference 0.0) and still differ by 0.79 and 0.62 in raw score (the 7.5 s and 7.6 s clips); on the all-zero window of the synthetic clip by 0.37, and the human class ranks 3 here and 591 there. The V2.4 graph rescales every window by its own range, so a constant window becomes rounding noise and the two builds’ rounding differs. Real recordings contain neither, but a microphone that drops out does: a digital-silence window is masked here (its human class ranks 3rd) and so are its neighbours, and BirdNET-Pi’s is not (rank 591), so a bird next to a dropout can be lost here and kept there. 10 windows in the second set, one clip (the other 4 digital-silence windows, ESC-50’s padding, agree).
- Atypical non-bird sounds: inherent. The largest gaps in ordinary windows (1.34 on a cough, 1.16 on every window of a pure 1 kHz tone, label Siren) are the same on identical input: the two builds differ by that much on content a bird model never sees. None is near a threshold.
- Weights: inherent. On identical input the FP32 and FP16 builds differ by a median 0.009 and, at the 99th percentile, 0.37 in raw score. That is what moves the robin from 0.6931 to 0.7025.
- A quiet, band-limited recording that had to be resampled: inherent sensitivity, triggered by the resampler. The 22.05 kHz version of the first set’s dawn-chorus clip has one window (3 to 6 s) whose raw score for one label differs by 1.55. Its resampled input differs from BirdNET-Pi’s by 0.96 % RMS (at most 1.8 least-significant bits of 16); on identical input the two models differ by 0.006, and feeding either model the other resampler’s input moves it by 1.54. BirdNET-Pi’s own model moves that much for an input change that small on a very quiet window. No stored row changes.
- The resampler (fixed). The earlier version of this page called the 28 % input difference on the 44.1 kHz clip “the model’s sensitivity to a nearly silent input, not a fault in either resampler”. It was a fault in this project’s: the resampling library delays its output by
128 x ratio - 1samples, the code removed only the whole-sample part, and every recording whose rate is not 48 kHz arrived early by 0.69 sample at 44.1 kHz (0.38 at 22.05 kHz, 0.67 at 48 to 32 kHz, 1.0 at 32 to 48 kHz). Shifting this project’s chunk by the measured 0.689 sample moved the raw score from 0.863 back to 1.201 (BirdNET-Pi: 1.202). The delay is now made a whole number and removed; the remaining 0.005 to 0.017 sample is the library’s own interpolation grid. BirdNET-Pi’s recorders write 48 kHz, so most stations never resample; RTSP streams, uploaded files and 32 kHz (V3.0) analysis do. - Fractional
OVERLAP(not fixed, tiny). BirdNET-Pi computes the step asint((3 - overlap) x 48000)in 64-bit floats; this project as3 x 48000 - int(overlap x 48000)in 32-bit. For 14 of 59 overlaps in steps of 0.05 the step differs by one or two samples (0.7: 110 399 against 110 400), which cannot matter, except that when the last window would hold exactly the 1.5 s minimum the extra sample decides whether it exists: 48 of 9 145 combinations, never atOVERLAP=0, and for a 15 s segment only at 2.10.
The default V3.0 path, against an independent reference
There is no BirdNET-Pi to compare V3.0 with, so tools/parity/v3_reference.py is a reference that shares no code with the pipeline: soundfile, a plain-mean mono mix, soxr (not rubato) for the resample to 32 kHz, slicing into 4.5 s windows, onnxruntime. It was run on all 120 recordings of both sets and compared with tests/parity_dump.rs on the V3.0 model: 119 recordings give a comparison (the digital-silence clip makes the model output NaN in the reference; this project treats a non-finite score as 0), 655 windows, the same window count on every recording. The top species is the same in 650 of 655 windows; the five that are not are every window of the pure 1 kHz tone, where two species tie at 0.026. On the 650 windows with the same top species the probabilities differ by a median 0.00003, a 99th percentile 0.0003 and at most 0.0040; the 200 of them that score 0.7 or more differ by at most 0.0040. This checks the front end (decode, resample, window) and the confidence mapping of the default model; it does not say V3.0 identifies birds better or worse than V2.4.
What the human-sound rule does on V3.0
The rule is BirdNET-Pi’s, written for V2.4, where a window of speech puts a human class among the model’s ten best. On the same 91 clips the rule masks 246 of 665 windows (37.0 %) on V2.4 and 18 of 459 (3.9 %) on V3.0. On the five LibriVox speech clips it masks 26 of 44 windows on V2.4 and 0 of 31 on V3.0, on three ESC-50 laughing, coughing and crying clips 6 of 6 and 0 of 3, on five crowd recordings 41 of 50 and 7 of 35. On one of those speech clips V3.0 gives its human classes a probability of 0.002 or less in all seven windows (rank 12 to 55 of 11 560 classes), so a confidence threshold would not catch it either (PRIVACY_THRESHOLD 0.01 to 0.03 is below that). On a V3.0 station the default does not keep conversation out of the table, and a station that opted into V3.0 should not rely on it for privacy. Nothing in this project measured a setting that does.
What is not the same, whatever you do
With the defaults above, a default station still differs from BirdNET-Pi’s in the ways below. Each was measured this session; tools/parity/defaults_audit.py prints the full table of defaults with the file and line of every cell (a copy is in tools/parity/golden/defaults_table.md) (it refuses to run if a cited line no longer holds the cited text).
| BirdNET-Pi | birdnet-go (from its source, not run) | This project | |
|---|---|---|---|
| Default model | V2.4 | V2.4 | V2.4 for a new install; BirdNET+ V3.0 preview is a choice (--classifier birdnet-v3) and an older station keeps the model it has |
SENSITIVITY | sigmoid slope clamp(2 - S, 0.5, 1.5); default 1.25 gives 0.75 | the slope itself; default 1.0 | BirdNET-Pi’s, on V2.4; ignored by V3.0 |
| Confidence threshold | 0.7, >= | 0.8 in code (0.7 in its shipped config file), strictly > | 0.70, >= |
| Overlap | 0.0 | 0.0 in code, 1.5 in its shipped config file | 0.0 |
| Range filter | MData v1 (v2 optional) | its own latest model, threshold 0.01 | v2 with V2.4 (v1 is not hosted as ONNX); V3.0 has its own geomodel; threshold 0.03 |
| Week asked of the range filter | ISO week 1 to 53, raw | 48-week year (four per month) | 48-week year |
| No location set | passes 0.0, 0.0 (or -1, -1) to the model | filter skipped | filter off; every species passes. An explicit 0.0, 0.0 is used as given: the magpie recording stores nothing, as in BirdNET-Pi |
| Human sounds | any human class in the top 10 masks the window and its neighbours | the human detection alone is dropped; filter on | BirdNET-Pi’s rule, on by default (PRIVACY_RANK 10) |
| Rows | every window that clears the bar | merged over 15 s, adaptive threshold on by default | every window that clears the bar; the duplicate, corroboration and adaptive-threshold features are off by default |
| Stored confidence / week | 4 decimals / ISO week | 2 decimals / none | full precision / 48-week-year week |
| Time of a row | the file name’s time plus the window’s start | the wall clock when the result is made | as BirdNET-Pi |
birdnet-go was not run. Applying its rules (score sigmoid(logit), strictly above 0.8 or 0.7, one row per species per 15 s, human classes dropped, no range filter, no adaptive threshold) to this project’s model outputs on the 77 recordings of the second set that both sides cut would store 18 rows (threshold 0.8) or 24 rows (0.7) where BirdNET-Pi stores 27 (tools/parity/birdnet_go_estimate.py). It is an estimate of what the rules do to the same scores, not a measurement of birdnet-go, and the two are not interchangeable in either direction.
The range filter: lists differ on almost every day. Three things differ at once between BirdNET-Pi as installed and a new install here: the model (MData v1, v2), the week it is asked about (ISO, 48-week), and nothing else. Measured on eight stations (London, Ithaca, Helsinki, Sydney, São Paulo, Nairobi, Hokkaido, Austin) for every day of 2024, 2026 and 2028 with BirdNET-Pi’s own range models (tools/parity/golden/range_calendar.json, SF_THRESH 0.03):
- The calendar alone (BirdNET-Pi with v2 and its ISO week against this project): the lists differ on 219 to 341 days of the year depending on the station and the year (2026 has an ISO week 53), by a mean of 5 species (Sydney) to 33 (Ithaca), at most 185 species on one day. This is not only a December effect: an ISO week number between 1 and 48 is read by the model as the same-numbered week of a 48-week year, which is up to four calendar weeks away. Weeks 49 to 53 give the model no season, and it answers with one year-round list about twice as long as any real week’s.
- The model alone (v1 against v2, same ISO week): different on every day, by a mean of 17 species (Helsinki) to 141 (São Paulo), because v2 admits many more species in the tropics.
- Both together, BirdNET-Pi as installed against a new install here, symmetric difference a mean of 29 (Helsinki) to 142 (São Paulo) species; admitted by BirdNET-Pi only, a mean of 1.5 (Sydney) to 35 (Hokkaido) species; by this project only, 10 (Helsinki) to 132 (São Paulo). The ranges cover all three years.
What that does to stored rows was not separable in the recordings tested: BirdNET-Pi’s rows are the same under v1/ISO, v2/ISO and v2/48-week on all 120 clips (79 rows on the second set, 12 on the first), because the birds in those recordings are common ones all three lists admit. The 120 clips do not contain a species that falls between the lists, so the effect on a station’s rows for a rare or out-of-season bird is unmeasured; the Species, Why isn’t a bird heard here? page shows it for any bird.
No location set. BirdNET-Pi’s installer asks ip-api.com for a location and falls back to 0.0, 0.0 when that fails (tools/parity/README.md). At 0.0, 0.0 with the v1 filter and SF_THRESH 0.03 the filter admitted 468 species at ISO week 8, and BirdNET-Pi’s own Pica pica test recording produced no detections (tools/parity/golden/matrix/a0_shipped_literal.json: detections: [], mdata.count: 468). Here a station with no coordinates stores all three magpie rows and a station with 0.0, 0.0 typed in stores none (run for this page on the real binary). No installer, quick-start script or default here writes 0.0, 0.0.
Species lists. birdnet-go’s species.include is a different thing from BirdNET-Pi’s include list: it means “always include these despite the range filter”, which is BirdNET-Pi’s whitelist. A list carried over by name from one to the other flips meaning. Here the three lists mean what they mean in BirdNET-Pi. (27 scenarios through BirdNET-Pi’s own run_analysis, same files read by the importer): include and exclude by scientific name, the Scientific_Common lines the species picker writes, CRLF and padded lines, a whitelist that rescues a species the occurrence list rejects, the whitelist that loses to an exclude or an include list, an exclude that beats an include, and SF_THRESH 0 and 0.5 all agree (21 scenarios). They differ where BirdNET-Pi’s include list is a wall that a bad entry turns into “record nothing”: an include list holding only a typo, only a common name, only a lower-case name, or only a blank line makes BirdNET-Pi record nothing at all, and makes this station ignore the list and record everything (4 scenarios); and SF_THRESH 1.0, where the model admits no species at all, which BirdNET-Pi reads as “filter off” and this project as an empty allow-list (2 scenarios).
File names. Of 33 names put to BirdNET-Pi’s ParseFileName and to this project’s parser, the date, time and RTSP id agree on every name both read. BirdNET-Pi also reads 2026-2-16-birdnet-…, a time without a leading zero, no or differently-cased birdnet, and no extension (this project refuses them). And this project reads six names that BirdNET-Pi rejects with an error and skips: a date or time that is not real (2026-02-30, 2025-02-29, month 13, 25:00:00, minute 60, second 60). It then stores their rows under the name’s date and time as written (2026-02-30 as 2026-03-02, 25:00:00 as the time 25:00:00); only a hand-named file can have such a name. At the year boundary BirdNET-Pi calls 31 December 2026 and 1 January 2027 both ISO week 53 and this project calls them weeks 48 and 1 of its 48-week year.
Other things that can keep a row out of the table here: a recording dated before 2024 or after 2063 is quarantined rather than stored; when more than 40 segments are waiting, the CPU passes 80 °C, or a Pi reports throttling, every other segment is skipped and counted; and a file that is not named YYYY-MM-DD-birdnet[-ID]-HH:MM:SS.wav is refused. BirdNET-Pi does none of these.
Not measured
BirdNET-Pi’s FP16 model on a real Raspberry Pi; birdnet-go itself (only its source and an estimate above); any model other than V2.4 against a reference except V3.0’s front end; Perch and a second classifier; the effect on a station’s rows of the three range filters on a rare bird; recordings longer than 30 s; any rate other than 44.1 and 48 kHz on real recordings (22.05 kHz only on one clip); the numpy 1.x comparison (np.float32(0.7) >= 0.7 is false there, true under numpy 2, and no window sat on it).
Seeing what the range filter does
BirdNET-Pi has no page that says why a bird is missing; this station does. Species → Why isn’t a bird heard here? (/species/occurrence, and GET /api/v2/species/occurrence for scripts) asks the same range filter the detection loop uses, for the station’s location and any week of the model’s 48-week year, and says for each species whether it is admitted and why. It is the quickest way to see the December effect above: ask about the same bird in week 47 and in week 48. See Why isn’t a bird heard here?.
Checking it yourself
# the reference side: BirdNET-Pi's code and model, goldens in tools/parity/golden
tools/parity/regenerate.sh
# the first set, Rust side, against the first set's golden
PARITY_MODEL=… PARITY_LABELS=… PARITY_WAVS=a.wav:b.wav PARITY_OUT=out \
cargo test --all-features --test parity_dump -- --nocapture
python3 tools/parity/compare_rust.py out/ours.json tools/parity/golden/matrix/a1_station.json
# the second set (106 clips): fetch, run BirdNET-Pi on it, run this project on it, compare with intervals
python3 -I tools/parity/fetch_fixtures.py DIR --manifest tools/parity/fixtures_extended.json --strict
python3 -I tools/parity/run_extended.py --pi-root PI --fixtures DIR --out OUT --as-year 2026
python3 tools/parity/compare_ext.py out/ours.json OUT/x0_pi_default.json
# every default, with its file and line, in all three projects
python3 tools/parity/defaults_audit.py --pi PI --go BIRDNET_GO --repo .
CI fetches the V2.4 model and range filter, checks their sha256, and runs the V2.4 tests on every change to the inference path: v24_matches_birdnet_pi, v24_range_filter_matches_birdnet_pi, v24_range_filter_every_week_matches_birdnet_pi, species_lists_match_birdnet_pi, chunking_matches_birdnet_pi, file_names_match_birdnet_pi and resampler_matches_resampy_parity.
Common Tasks
Short, copy-pasteable answers to “how do I…?” Each links to the page with the full story.
Get a phone alert when a rare bird shows up
- Create an Apprise URL for your service (Telegram, Pushover, ntfy, Discord…).
- Set it:
BIRDNET_NOTIFY_URLS=tgram://bottoken/chatid(or via/admin/settings → Notifications). The station sends to Telegram directly —BIRDNET_APPRISE_URLis the base URL of an Apprise server, not a destination. - Set
BIRDNET_NOTIFY_CONFIDENCE=0.85so only solid detections ping you. - Use the rare-bird rules so you’re alerted on new or unusual species, not every robin.
See Notifications & Integrations.
Get one alert per species per day instead of per-detection pings
Choose the First of each species, daily trigger (--notify-trigger new-species-daily, in the onboarding wizard or /admin/settings → Notifications) to get one alert per species per day instead of one per detection. The trigger modes are each, new-species and new-species-daily; there is no evening-digest mode — the weekly report e-mail is the closest thing to one.
Keep a recording forever
Retention is disk-based — old clips are purged once the disk fills. Open the Today page (or a species’ recordings), and use the lock action on the detection. Locked clips are never purged. See Recording & Retention.
Fix a misidentified detection
On the Today page, use re-label on the row to correct the species, or delete it if it’s pure noise.
Cut down on false positives
Nudge the confidence threshold up — see the full Tuning guide. For a single noisy species, add a per-species threshold instead of raising the global bar.
Listen to last night’s owl
Go to Recordings, filter the Clips list to Rare (or search the owl’s name), pick the clip, and play it inline. The History calendar is the fastest way to jump to a specific night.
Add a second microphone or an RTSP camera
Use Cameras and streams on the Capture tab, or set BIRDNET_RTSP_URLS= with comma-separated URLs. You can run several sources at once.
Expose the dashboard to the internet (safely)
The dashboard is LAN-reachable by default (0.0.0.0:8502) — fine at home, where viewing is open and /admin is protected by the auto-generated password. For anyone else on the network, --tls-mode self-signed brings HTTPS up on 8503 with a certificate the station generates itself. But never port-forward either port straight to a public IP: encryption is not exposure-proofing, and a self-signed certificate carries no publicly-trusted name. Put it behind a reverse proxy with a real certificate and a password, or use a VPN — see Remote Access & Security. To restrict it to the local machine instead, set BIRDNET_LISTEN=127.0.0.1:8502.
Send detections to Home Assistant
Set BIRDNET_MQTT_HOST and BIRDNET_MQTT_HA_DISCOVERY=1. The station registers itself automatically and the latest detection appears as Home Assistant entities. See Integrations Reference.
Back up everything before an upgrade
Download a full backup from the Data tab and keep it off the station — the automatic snapshots run weekly and live on the station’s own card, so they will not help you if the upgrade takes the card with it. Take one manually right before you update.
Move my data to a new Pi
Download a full backup from the Data tab on the old Pi, install on the new one, and restore the archive there. Your detections, settings and recordings come across. Restoring overwrites whatever is on the new station, so do it before you start collecting on it.
Import my old BirdNET-Pi history
See Migrating from BirdNET-Pi — it’s a safe, read-only import at /station/data#import (the old /admin/migrate redirects there).
Migrating from BirdNET-Pi
You can move a BirdNET-Pi station here and keep what it had: its settings, its detection history and the recordings that go with it. Each is a step on the Settings → Data page (/station/data#import), each can be done on its own, and each has a command-line twin. Nothing is written until you have seen what it would do and confirmed it, and BirdNET-Pi’s files are never changed.
Three things are different from BirdNET-Pi on the first day, whatever you import. Read What changes whether you like it or not once.
Before you start
-
The sign-in is
admin, notbirdnet. BirdNET-Pi asks for the userbirdnetand the password inCADDY_PWD. This station’s administrator isadmin. The import does not copy your password (an import never writes one); set the password under Access, or, if you put your BirdNET-Pibirdnet.confin place as this station’s own file, itsCADDY_PWDbecomes the password ofadminthe next time the station starts. -
Stop BirdNET-Pi if it is on the same machine, so it is not writing while you copy:
sudo systemctl stop birdnet_* -
Find the files. In BirdNET-Pi’s folder,
~/BirdNET-Pi:File Holds birdnet.confthe settings ( /etc/birdnet/birdnet.confis a link to it)apprise.txtwhere notifications go body.txtthe notification message include_species_list.txt,exclude_species_list.txt,whitelist_species_list.txtthe three species lists scripts/birds.db(orscripts/BirdDB.txt)the detections and the recordings in
~/BirdSongs/Extracted/By_Date. If BirdNET-Pi is on another machine, copy these to a place this station can reach, or upload them from your browser.
Step 1 — your settings
On Settings → Data, under Bring your BirdNET-Pi settings, choose birdnet.conf and any of the other files (select several at once; they are recognised by their names), or paste the contents of birdnet.conf, or give the folder on this device. Press Show me what would change.
You get a table with a row for every key in your file: what it is called there, your value, what it becomes here, and what happens to it. Credentials are shown only as their length. The rows are:
| Verdict | Meaning |
|---|---|
| Will be set | Carried over as it is. |
| Will be set, converted | Carried over, with the value changed on the way (a unit, a name, a rule); the row says how. |
| Look at this | Carried over or not, but there is something to read first. |
| Goes in birdnet.conf | This station reads it only from its own birdnet.conf (the Flickr key, for example); the lines come back as a download after you apply. |
| Not accepted here | The Settings page would refuse the value, and the row says why. Nothing is set from it. |
| Not carried over / Nothing to import | There is no equivalent, or nothing to carry; the row says which and why. |
Press Apply these settings and the same checks the Settings page runs are run over the whole set; if any value would be refused, none is written. Then restart the station if it says some settings need it.
The species lists are added to the lists this station already has, never replacing them, and apprise.txt is copied into the data folder (as apprise-from-birdnet-pi.txt, readable by the station only) and used as the Apprise configuration. That copy is not part of the full backup, which holds the database, birdnet.conf and the recordings: the setting that points at it is in the database, so a station restored from a backup onto another machine needs apprise.txt copied in again, or its notifications stop. The file is written (to a temporary name, then renamed into place) only after every setting has passed the Settings page’s checks, and just before they are saved. A second import applied after you have changed something by hand sets the BirdNET-Pi values again; the preview shows what each row replaces.
From a terminal, the same, looking only:
birdnet-behavior --import-birdnet-pi-conf ~/BirdNET-Pi
and with --apply-import it writes. It prints the same table, exits 0 when it showed a plan or finished, 1 when the settings would be refused (nothing written), 2 for a path it cannot use.
If you run --doctor on a birdnet.conf copied over as it is, every key BirdNET-Pi writes now draws an answer: the key to rename it to, the setting to use instead, or why there is nothing.
Step 2 — your detection history
BirdNet-Behavior imports an existing BirdNET-Pi history safely and non-destructively — the source database is opened read-only and no row of it is ever changed. (If it is in WAL mode, SQLite may create -shm and -wal bookkeeping files beside it while it is read; that is the other reason to stop BirdNET-Pi first. An uploaded file is held in migration-staging, next to this station’s own database, and removed together with those files once it is imported or replaced.) It can import your own station’s history, which is the common case and needs almost no thought, or another station’s, which needs two answers from you before it will be right.
The common case: your own station’s history
- Open Settings → Data → Import, or go straight to
/station/data#import. - Give it the file — either Upload it, or switch to Server Path and type the absolute path to
~/BirdNET-Pi/BirdDB.txt(orbirds.db). - Review the preview — schema, total detections, unique species, date range, duplicate count, and a checklist with ✔ / ⚠ / ✘ against each validation.
- Click Start Import.
- Verify the per-species count comparison when it finishes.
Leave the two “Where did this recording come from?” fields blank. Blank means “this is this station, on this clock”, which is what makes the import a no-op reconciliation.
Duplicate rows are skipped, so re-running the import is safe — if it is interrupted, run it again.
Importing another station’s history
This is a different operation and the form treats it as one. Two facts drive everything below:
- BirdNET-Pi stores local wall-clock time with no time zone. A history recorded at UTC−5 and read by a UTC+1 station is six hours out, and nothing in the file says so. Every hour-of-day analytic — the dawn chorus, the heat map, sessionisation, peak windows — would average the two clocks together.
- A different place is a different set of birds. Sunrise, habitat and species pool all differ, so “first of year” and species richness stop meaning one thing.
Tell it where the file came from
Fill in both fields before importing:
| Field | What to put |
|---|---|
| Source station name | Anything that will mean something to you later — “Hollow Oak, north transect”. Stored with the batch and shown beside it. |
| Source station’s UTC offset | The source station’s standard offset, in seconds, east-positive. UTC−5 is -18000; UTC+1 is 3600. |
Each timestamp is then converted individually — source local → the real UTC instant → this station’s local time for that instant — so the destination half of the conversion is exactly right on both sides of every daylight-saving change this station observes.
What a single offset cannot do. If the source station observed daylight saving, roughly half its history carries a different real offset than one number can describe, and those rows land an hour out. Recovering that needs the source’s IANA time zone, which BirdNET-Pi does not record. The number you enter is stored with the batch, and the whole import can be removed, so this is recoverable — but it is not automatic.
What the validator will tell you
The preview compares the file’s coordinates against this station’s and reports:
- a ⚠ warning when the source is far enough away to be a different site — the batch is then tagged
NNN km awayin the provenance list; - a ✘ failure, which refuses the import, when the file itself contains detections from several different coordinates. Such a file is already a merge of several sites and cannot be attributed to one place.
The distance check is a warning, not a block: merging two sites is a legitimate thing to want, and only you can say whether two coordinates are one station whose GPS fix moved or two sites a county apart.
Decide whether imported rows count
Under Imported histories there is one checkbox:
Keep imported detections out of the analytics
- Off (the default) — imported detections count as this station’s own everywhere: life list, first-of-year, species richness, phenology, the heat map, co-occurrence, the dawn chorus.
- On — they stay in the database and in the Recordings browser, but stop contributing to any of those.
Turn it on when you have imported another site and want your station’s own numbers back. It applies to both the detection database and the DuckDB analytics copy, so the two cannot disagree.
Undoing an import
Every imported detection is tagged with the batch that brought it in. Each batch in the Imported histories list has a Remove action that deletes exactly those rows — nothing this station heard itself is touched. Merging another site’s history is a decision you can take back.
After the history import
The import writes back-dated history straight to SQLite, so the DuckDB analytics copy is rebuilt afterwards — otherwise the incremental startup sync would skip every imported row as “older than the latest already synced”. The progress card shows this as a second stage; on a large history it takes a while, and the behavioural and time-series pages are thin until it finishes.
Step 3 — your recordings
The history import copies rows. Each row keeps its File_Name, but the recording it names is still in BirdNET-Pi’s folder, so an imported detection would have nothing to play and no spectrogram. The third step, Your recordings, brings them across.
BirdNET-Pi lays its clips out as ~/BirdSongs/Extracted/By_Date/<date>/<Common_Name>/<File_Name>, with a picture <File_Name>.png beside each and a By_Date/shifted/ folder of pitch-shifted copies. This station keeps clips in one folder under the same File_Name, and draws a spectrogram from the audio when you look at one. So the step takes the audio of the detections you imported (never a clip no detection names, never the pictures, never the shifted copies) and puts it where this station serves clips from.
Give it BirdNET-Pi’s folder (~/BirdSongs, or the Extracted or By_Date folder inside it) and choose what to do with each file:
| Choice | What happens | Needs |
|---|---|---|
| Copy | A copy; BirdNET-Pi keeps its own. | room for the copy |
| Link | The same file under a second name; nothing is copied. | BirdNET-Pi’s folder and this station’s recordings on one disk |
| Move | The file is copied, then removed from BirdNET-Pi’s folder. BirdNET-Pi loses it. | room for the copy; the button asks you to confirm |
| Only count them | Nothing is touched; it reports what is there. |
Check first runs the same code with the writing switched off and tells you how many recordings it found, how much space they take, how many are already here and how many it could not find. It also refuses a copy that would take the disk past the point at which this station starts deleting its oldest clips (DISK_PURGE_THRESHOLD, 95 % unless you changed it): copying past that would begin a purge of the history you just brought, and of the station’s own recordings. It warns when some of the clips are older than the Keep clip audio setting, which would delete them at the next cleanup.
It is careful with your files:
- A name already here with the same size is “already there”; with another size it is a conflict and is left alone. It never overwrites.
- A copy is written under a temporary name and renamed into place, so a run that stops leaves either a whole clip or a leftover the next run removes. Run it again to carry on.
- A file name from the database is used only if it is a plain file name; one with a separator, a
.., a leading dot or no audio extension is skipped. A link in BirdNET-Pi’s folder that points out of it is not followed. - A clip that is not where its row says (a species folder renamed since) is found by name in the tree.
From a terminal:
birdnet-behavior --import-birdnet-pi-clips ~/BirdSongs # look
birdnet-behavior --import-birdnet-pi-clips ~/BirdSongs --apply-import # copy
birdnet-behavior --import-birdnet-pi-clips ~/BirdSongs --clips-mode hardlink --apply-import
Protected clips are locked. BirdNET-Pi’s protected clips (disk_check_exclude.txt, in ~/BirdNET-Pi/scripts/) become locked detections here when you bring the recordings across, and a locked clip is never purged or removed by the species cap. The list is looked for beside the recordings (~/BirdNET-Pi/scripts/ next to ~/BirdSongs); if BirdNET-Pi is elsewhere, name its folder in Where BirdNET-Pi is installed (the command line looks only beside the recordings). The check says how many will be locked, and the result how many were. Only detections an import brought in are locked, matched by clip file name; a name on the list that matches none yet (the history is not imported) is counted as such, and running the step again after the history is in locks it.
What changes whether you like it or not
These are not options the import can hide. The mapping table below says it key by key; this is the short list.
- The sign-in is
admin, as above. - Clip links. BirdNET-Pi serves its clips from a public, predictable address,
/By_Date/<date>/<species>/<file>, which anyone on the network can browse without signing in. This station serves a clip at/api/v2/recordings/<file>and shares one as a signed link,/r/<token>, that expires. A bookmark or a link from another page to an old/By_Date/...address no longer works. Notifications sent from here carry the new links. - Clip format. BirdNET-Pi saves
mp3by default; a new install here saveswav. The import sets the format you had (AUDIOFMT).mp3,flacandoggneedffmpegorsoxon the station, and a clip falls back to WAV with a log line without one. - Notifications. BirdNET-Pi’s three switches — every detection, a species seen five times or fewer in a week, the first of a species each day — are one list of rules here. When more than one matches a detection, one message is sent that names every reason (
$reason); BirdNET-Pi sends one per reason. All three switched off is no notifications, as there. Abirdnet.confthat was never edited has all three at 0 (that is how BirdNET-Pi installs it), so importing it turns notifications off here; the preview says so in a row of its own at the top, and a notification rule is switched on under Settings, Notifications. BirdNET-Pi has no minimum confidence for notifications and notifies for every detection it stores; this station has one (0.80 unless set), so the import sets it to 0 and the same detections notify. BirdNET-Pi counts “the last 7 days” as 8 calendar days; this counts 7.$confidenceprints two decimals here, BirdNET-Pi’s raw number there. The weekly report is a weekday here, not Monday at 01:00.APPRISE_ONLY_NOTIFY_SPECIES_NAMESis, despite its name, the exclusion list and the_2key the inclusion list; the importer knows. - The occurrence filter is version 2 here; BirdNET-Pi’s default is version 1, which is not available as ONNX. The week it is asked about is also counted differently (this station uses a 48-week year, BirdNET-Pi the ISO week), and not only in December. Measured with BirdNET-Pi’s own range models, the lists differ on most days of the year: the calendar alone on 219 to 341 days a year, by a mean of 5 to 33 species; version 1 against version 2 on every day, by a mean of 17 to 141. Over the 120 clips tested, BirdNET-Pi’s own stored rows were the same under all three combinations, but those clips held no bird that falls between the lists, so a rare or out-of-season bird may differ. Same audio, same detections has what was measured, and Species, Why isn’t a bird heard here? shows it for any bird.
- The species cap (
MAX_FILES_SPECIES) keeps the N newest clips of a species here and never a locked one; BirdNET-Pi keeps the N most confident (plus everything from the last 8 days and every protected clip). If you use it, read the row. - When the disk is full,
FULL_DISK=purgeremoves the oldest clips of the most-recorded species first, never below five clips of a species and never a locked one (BirdNET-Pi removed about a day’s worth of the oldest files at a time).FULL_DISK=keepstops recording and deletes nothing, as in BirdNET-Pi, and records again by itself once usage is 2 percentage points below the purge threshold (the margin stops a disk sitting at the threshold from stopping and starting every cycle; a raised threshold applies after a restart). While it is stopped the station logs an error, sends an alert if notifications are set up, and lists it at/api/v2/health/conditions(birdnet_recording_heldis 1 in the metrics). - Privacy. BirdNET-Pi’s
PRIVACY_THRESHOLDis a percentage of the species list; here it is a confidence, and the rule BirdNET-Pi applies isPRIVACY_RANK. The import converts it (max(10, 60 × value)) and sets the confidence rule to 0. - Position. BirdWeather uploads carry this station’s position, as before. A position of exactly 0, 0 (what BirdNET-Pi writes when it cannot find one) is not copied.
- The network name. The station advertises itself on the local network as a web service named
<name> on <host>whileavahi-daemonruns; its address ishttp://<this machine's name>.local. BirdNET-Pi’savahi-aliaspublished a second name,birdnet.local; this does not.
The mapping table
Every key BirdNET-Pi writes into birdnet.conf, what it does there (from its own code), and what becomes of it. This table is generated from the importer’s table in the source (birdnet_migrate::conf::RULES); a test fails when this page and the importer differ, so what is written here is what the importer does. Keys marked as secrets are never shown or logged by the importer.
| BirdNET-Pi key | What it does there | Here | What changes |
|---|---|---|---|
SITE_NAME | title in the web interface’s banner (common.php) | setting site_name | BirdNET-Pi’s default is the shell variable $HOSTNAME, which is not copied as text. |
LATITUDE | position for the occurrence filter and the weather of a detection | setting latitude | A position of 0, 0 (what BirdNET-Pi writes when it cannot find one) is not copied: a station without a position runs without the occurrence filter here, where BirdNET-Pi asked the model about the Gulf of Guinea. |
LONGITUDE | position for the occurrence filter and the weather of a detection | setting longitude | See LATITUDE. |
MODEL | which classifier runs: BirdNET V1, V2.4 FP16 (the default), Perch v2 or a BirdNET-Go classifier (utils/models.py) | not carried over (see the note) | The classifier is chosen on the Classifier page, not copied: a new install here runs BirdNET V2.4, the same network BirdNET-Pi runs by default (32-bit floats where BirdNET-Pi uses 16-bit). |
SF_THRESH | how likely a species must be here, this week, to be accepted (occurrence filter threshold) | setting sf_thresh | The same number with the same meaning, but see DATA_MODEL_VERSION. |
DATA_MODEL_VERSION | which occurrence model: 1 (the default) or 2 (utils/models.py: get_meta_model) | not carried over (see the note) | Version 1 is not offered here (it is not hosted as ONNX): this station uses version 2 with V2.4, and asks it about a week of its own 48-week year, not the ISO week. Measured with BirdNET-Pi’s own range models: the species lists differ on most days of the year (the calendar alone, on 219 to 341 days a year, by a mean of 5 to 33 species; version 1 against version 2 on every day, by a mean of 17 to 141). On the 120 clips tested, BirdNET-Pi’s own stored rows were the same under all three (version 1 with the ISO week, version 2 with the ISO week, version 2 with the 48-week year), and a bird that falls between the lists was not among them (see “Same audio, same detections”). |
BIRDWEATHER_ID (a secret; never shown or logged) | station ID for uploading detections and soundscapes to BirdWeather (reporting.py) | setting birdweather_token | Uploads resume, with this station’s position, as before. Without this the uploads stop and nothing says so. |
HEARTBEAT_URL (a secret address; only its scheme and host are shown, never logged) | address pinged after every analysis (reporting.py) | setting heartbeat_url | Pinged on a schedule here rather than after every analysis. Such addresses carry the monitor’s key, so the preview shows only its scheme and host. |
FLICKR_API_KEY (a secret; never shown or logged) | key for the Flickr image provider | FLICKR_API_KEY in birdnet.conf | Read from birdnet.conf only: there is no field for it in the browser. |
FLICKR_FILTER_EMAIL | show only this Flickr user’s photographs | FLICKR_FILTER_EMAIL in birdnet.conf | Read from birdnet.conf only. |
IMAGE_PROVIDER | where species pictures come from: WIKIPEDIA, FLICKR, or blank for none | IMAGE_PROVIDER in birdnet.conf | Read from birdnet.conf only, in either case. Blank means no pictures in BirdNET-Pi and Wikipedia here. |
INFO_SITE | which site a species name links to: ALLABOUTBIRDS or EBIRD (common.php) | setting info_site | Lower-case here. This station’s own default is eBird, so it is set explicitly. |
DATABASE_LANG | language of the common names stored with a detection | setting database_lang | Names are translated when shown here, not when stored; the history you import keeps the names it was recorded with. |
CADDY_PWD (a secret; never shown or logged) | password of the birdnet user for BirdNET-Pi’s web interface (Caddy basic auth, common.php) | not carried over (see the note) | Not copied, so no import ever writes a password. This station’s administrator is admin, not birdnet; set its password under Access. (If you put this file in place as this station’s own birdnet.conf, CADDY_PWD becomes the password of admin the next time the station starts.) |
ICE_PWD (a secret; never shown or logged) | password between BirdNET-Pi’s streaming programs (Icecast) | no equivalent | This station streams from inside one program; there is nothing to authenticate. |
BIRDNETPI_URL | public address of the site; used by Caddy and as the base of a notification’s links (notifications.py) | not carried over (see the note) | Read from the environment as BNB_PUBLIC_URL, not from a setting. Notification links are built from it; without it they use http://<hostname>.local. |
REC_CARD | sound card for the recorder: arecord -D, or default (birdnet_recording.sh) | setting alsa_device | default is the system’s default input, as in BirdNET-Pi. Clear the field to let this station find a USB microphone by itself. |
RTSP_STREAM (a secret address; only its scheme and host are shown, never logged) | comma-separated network audio streams that replace the sound card (birdnet_recording.sh) | setting rtsp_urls | Comma-separated here too. The preview shows only each address’s scheme and host; the user name, password and path are not shown. |
RTSP_STREAM_TO_LIVESTREAM | position of the stream the live-audio page plays (0 = the first) | not carried over (see the note) | Streams are not numbered here: choose the default live source again (livestream_source). |
CHANNELS | channel count the recorder asks the sound card for (arecord -c) | not carried over (see the note) | Per audio source here: the station reads the device’s channels and reduces stereo to mono by a rule chosen on the Audio page (--channel-report records and compares). |
RECORDING_LENGTH | length in seconds of each recording BirdNET-Pi analyses (3 to 60) | setting segment_duration | |
EXTRACTION_LENGTH | seconds of audio saved around a detection; blank = 6 | setting extraction_length | |
AUDIOFMT | format of the saved clip, any sox format (reporting.py: extract_detection) | setting audio_format | BirdNET-Pi’s default is mp3; a new install here saves WAV. This station writes wav, mp3, flac and ogg; mp3, flac and ogg need ffmpeg or sox, and fall back to WAV with a log line without one. |
CONFIDENCE | lowest confidence stored as a detection (analysis.py) | setting confidence_threshold | |
SENSITIVITY | sigmoid sensitivity of the classifier, 0.5 to 1.5 | setting sensitivity | The same number, with the same meaning, on V2.4. |
OVERLAP | seconds two analysis windows overlap, 0 to 2.9 | setting overlap | |
PRIVACY_THRESHOLD | percent of the species list within which a human sound blanks a window (analysis.py: filter_humans) | setting privacy_rank and privacy_threshold = 0 | A percentage there, a rank here: privacy_rank is max(10, 60 x value), the same rule. privacy_threshold, a confidence here, is set to 0 so the value is not read twice, in two scales. |
FULL_DISK | what to do at the purge threshold: purge the oldest day’s clips or keep everything and stop the services (disk_check.sh) | setting full_disk_action | keep here stops recording and deletes nothing when the disk is full, and records again by itself once usage is 2 percentage points below the purge threshold (a raised threshold applies after a restart). purge removes the oldest clips of the most-recorded species first, never below 5 clips of a species, never a locked clip. |
PURGE_THRESHOLD | disk percentage at which disk_check.sh acts; 95 if unset | setting purge_threshold | Called DISK_PURGE_THRESHOLD in this station’s birdnet.conf. |
MAX_FILES_SPECIES | clips kept per species; 0 keeps all (disk_species_clean.sh) | setting max_files_per_species | A different rule: BirdNET-Pi keeps the N most confident clips of a species, plus everything from the last 8 days and every protected one. This station keeps the N newest clips of a species, and never a locked one. A BirdNET-Pi “protected” clip is a locked one here, and bringing the recordings across locks the imported clips on BirdNET-Pi’s protected list (see step 3, your recordings). |
APPRISE_NOTIFICATION_TITLE | title of a notification (notifications.py) | setting notify_title_template | The same $comname, $sciname, $reason and the rest are understood. $confidence prints two decimals here, BirdNET-Pi’s raw number there. |
APPRISE_NOTIFICATION_BODY | body of a notification, in releases before September 2025 (now body.txt) | setting notify_body_template | Later releases keep the body in body.txt, which the import reads too. |
APPRISE_NOTIFY_EACH_DETECTION | send a notification for every detection (notifications.py) | setting notify_trigger | The three switches are one setting here, a list of rules. When more than one rule matches a detection, one message is sent that names every reason; BirdNET-Pi sends one message per rule. All three off means no notifications at all, as there. |
APPRISE_NOTIFY_NEW_SPECIES | send a notification for a species seen five times or fewer in the last 7 days, this detection included | setting notify_trigger | See APPRISE_NOTIFY_EACH_DETECTION. BirdNET-Pi counts the last 8 calendar days (Date >= today - 7); this station counts 7. |
APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY | send a notification for the first detection of a species each day | setting notify_trigger | See APPRISE_NOTIFY_EACH_DETECTION. |
APPRISE_WEEKLY_REPORT | send the weekly report, Mondays at 01:00 (cron, weekly_report.sh) | setting weekly_report_schedule | A weekday here, not an hour. 1 becomes monday, 0 disabled. |
APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES | seconds before the same species may notify again; 0 = no limit | setting notify_cooldown | BirdNET-Pi’s default is no limit; this station’s is 300 seconds, so the value is always set explicitly. |
APPRISE_ONLY_NOTIFY_SPECIES_NAMES | species that are never notified (an exclusion list, despite the name; notifications.py: should_notify) | setting notify_species_exclude | Common names, comma-separated, as in BirdNET-Pi. |
APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2 | species that are the only ones notified (an inclusion list; notifications.py: should_notify) | setting notify_species_only | Common names, comma-separated, as in BirdNET-Pi. |
RAW_SPECTROGRAM | draw spectrograms without axes and labels (spectrogram.sh) | setting raw_spectrogram | This renderer draws no axes in either mode; the setting hides the species and confidence text. |
RARE_SPECIES_THRESHOLD | days without a sighting after which a species is called rare (overview.php) | setting rare_species_days | |
COLOR_SCHEME | light or dark page colours for everyone (common.php) | no equivalent | Chosen per browser here (the Display menu), so each person has their own. |
CUSTOM_IMAGE | the picture shown on the Overview page, a path or a web address (overview.php) | setting dashboard_image | BirdNET-Pi also accepts a web address (it reads the picture with file_get_contents); this station reads a file on this device, so a web address is not carried over. |
CUSTOM_IMAGE_TITLE | the heading over that picture | setting dashboard_image_title | |
FREQSHIFT_TOOL | how a clip is pitch-shifted when you ask for it on the Recordings page: sox or ffmpeg | no equivalent | BirdNET-Pi makes a shifted copy of a clip on demand. freq_shift_hz here rewrites every new clip as it is saved, which is not the same, so it is not set. |
FREQSHIFT_HI | upper frequency for the ffmpeg shift | no equivalent | See FREQSHIFT_TOOL. |
FREQSHIFT_LO | lower frequency for the ffmpeg shift | no equivalent | See FREQSHIFT_TOOL. |
FREQSHIFT_PITCH | pitch change for the sox shift, in hundredths of a semitone | no equivalent | See FREQSHIFT_TOOL. |
FREQSHIFT_RECONNECT_DELAY | milliseconds the live page waits when it switches the live stream’s shift | no equivalent | See FREQSHIFT_TOOL. |
ACTIVATE_FREQSHIFT_IN_LIVESTREAM | pitch-shift the live audio | no equivalent | See FREQSHIFT_TOOL. |
AUTOMATIC_UPDATE | update BirdNET-Pi from git every Sunday at 03:00 (update_birdnet.sh) | not carried over (see the note) | This station does not update itself from the network. Updates are applied by you, from the update page. |
SILENCE_UPDATE_INDICATOR | hide the number of commits behind | no equivalent | There is no such indicator. |
LogLevel_BirdnetRecordingService | ffmpeg log level of BirdNET-Pi’s recording service | no equivalent | BirdNET-Pi’s three log levels are for the programs it runs; this station is one program with one log level (LOG_LEVEL). |
LogLevel_LiveAudioStreamService | ffmpeg log level of BirdNET-Pi’s live-stream service | no equivalent | See LogLevel_BirdnetRecordingService. |
LogLevel_SpectrogramViewerService | sox log level of BirdNET-Pi’s spectrogram service | no equivalent | See LogLevel_BirdnetRecordingService. |
BIRDNET_USER | the Unix user BirdNET-Pi runs as | no equivalent | Internal to BirdNET-Pi. |
RECS_DIR | where BirdNET-Pi records and finds its audio | no equivalent | This station’s recordings directory is its own. Give the old folder to the recordings step of the move to bring the clips across. |
PROCESSED | where analysed recordings wait to be deleted | no equivalent | Internal to BirdNET-Pi. |
EXTRACTED | where BirdNET-Pi keeps its clips (By_Date/...) | no equivalent | Not read by this station. Give the old install’s folder to the recordings step of the move to bring the clips across. |
IDFILE | BirdNET-Pi’s list of species identified so far | no equivalent | Internal to BirdNET-Pi. |
LAST_RUN | debugging aid | no equivalent | Internal to BirdNET-Pi. |
THIS_RUN | debugging aid | no equivalent | Internal to BirdNET-Pi. |
PUSHED_APP_KEY (a secret; never shown or logged) | key for the Pushed.co service, dropped in 2022 | no equivalent | Pushed.co is not supported; use a notification address. |
PUSHED_APP_SECRET (a secret; never shown or logged) | secret for the Pushed.co service, dropped in 2022 | no equivalent | See PUSHED_APP_KEY. |
DB_PWD (a secret; never shown or logged) | password of a database BirdNET-Pi no longer uses | no equivalent | This station uses SQLite. |
DB_ROOT_PWD (a secret; never shown or logged) | root password of a database BirdNET-Pi no longer uses | no equivalent | See DB_PWD. |
DO_RECORDING | switch for BirdNET-Pi’s recorder | no equivalent | Internal to BirdNET-Pi. |
DO_EXTRACTIONS | switch for BirdNET-Pi’s clip extraction | no equivalent | Internal to BirdNET-Pi. |
BIRDNETLOG_URL | address of BirdNET-Pi’s log viewer | no equivalent | There is no separate log viewer. |
WEBTERMINAL_URL | address of BirdNET-Pi’s web terminal | no equivalent | This station has no web terminal. |
EXTRACTIONLOG_URL | address of BirdNET-Pi’s extraction log | no equivalent | There is no separate log viewer. |
EXTRACTIONS_URL | address of BirdNET-Pi’s extractions | no equivalent | Clips are served by this station itself. |
VENV | BirdNET-Pi’s Python environment | no equivalent | Internal to BirdNET-Pi. |
INSTALL_DATE | when BirdNET-Pi was installed | no equivalent | Internal to BirdNET-Pi. |
ANALYZED | where BirdNET-Pi keeps analysed files | no equivalent | Internal to BirdNET-Pi. |
TIMESTAMP_FORMAT | date format in an old release’s file names | no equivalent | Internal to BirdNET-Pi. |
What carries over, and what does not
Carries over: every detection row — date, time, scientific and common name, confidence, coordinates, and the BirdNET parameters (cutoff, week, sensitivity, overlap) — with the history import; the settings, species lists and notification files with the settings import; the recordings of the imported detections with the third step.
Two columns are not the same kind of number afterwards. An imported row keeps the Week BirdNET-Pi wrote, the ISO week (8 for 16 February 2026, 53 for 31 December 2026). A detection this station records stores the week of its own 48-week year (7 and 48 for those two days). The import does not convert them, so that an export of the imported rows matches your old install’s. A query that groups or filters by Week across imported and new rows therefore mixes two calendars. Imported rows are the ones with an import_batch_id (new ones have none), so either leave them out, or work the week out from Date instead. Likewise Confidence: BirdNET-Pi stores four decimals, and this station stores the score unrounded.
Does not carry over: BirdNET-Pi’s own spectrogram pictures (this station draws its own), the pitch-shifted copies, the confirmed_species_list.txt list, the protected-clip list, the Streamlit, Icecast, web terminal, file manager and Adminer tools it ships, and its sign-in. Its installation is left untouched and safe to restart.
FAQ
Is it accurate? Which model does it use?
A new install uses BirdNET V2.4 with its v2 occurrence filter — the network BirdNET-Pi and birdnet-go run — and BirdNET-Pi’s own defaults (CONFIDENCE 0.70, SENSITIVITY 1.25, and the human-sound rule on). Same audio, same detections says what was measured against BirdNET-Pi’s own code and where the two still differ. The BirdNET+ V3.0 preview is a different model, an explicit choice (--classifier birdnet-v3): it hears 4.5-second windows rather than 3, reports calibrated probabilities rather than raw scores, and covers about 11 500 species rather than 6 500, so its detections are not BirdNET-Pi’s and are not meant to be. Up to v0.17.1 V3.0 was installed for everyone, and a station that already runs it keeps it: no install or update changes a running station’s model.
The V2.4 files (about 85 MB) are installed on the first run, each checked against a SHA-256 compiled into the binary; see Installation for where they come from. The V3.0 files (541 MB, plus a 14 MB occurrence model) come from this project’s model release, with Zenodo as the fallback.
Do I need a Raspberry Pi?
No. It runs on any x86_64 Linux machine too. The Pi 5 is recommended and the Pi 4B and 400 are supported; older boards such as the 3B+ and Zero 2 W are not supported targets (see Hardware). There’s no Python interpreter or virtualenv overhead around the model, so it’s happy on modest hardware.
Does it work offline / without internet?
Yes, once set up. The only things that need the network are the one-time model install, optional Wikipedia photo caching, and any notification/upload channels you enable. The UI itself is fully self-contained — all fonts are embedded in the binary and it never calls out to a CDN.
Does it need a camera, or just a microphone?
Either. Point it at a USB microphone, or at an RTSP stream from an IP camera (many bird-box and feeder cams expose one). You can run several RTSP sources at once. See Audio & Microphones.
Where are my recordings and database stored?
Under /data in Docker (a named volume) or under the data directory on bare metal (~/BirdNet-Behavior for the service user): the SQLite birds.db, the recordings/ clips, the model (models/ on bare metal, /data/model in Docker) and the species-image cache (image_cache/ on bare metal, /data/cache in Docker).
How do I keep a recording I love from being deleted?
Retention is disk-based by default — the oldest clips are purged once the disk fills past the threshold — with an optional age limit (Keep Clip Audio (days) / CLIP_RETENTION_DAYS) that is off unless you set it. Lock any detection (on the Today page) to pin its recording permanently; locked clips are exempt from both. See Backups & Recovery.
Is the dashboard exposed on my network? Do I need a login?
By default the dashboard binds to all interfaces (0.0.0.0:8502), so it’s reachable from any device on your LAN. Viewing needs no login; the /admin* panel, the Settings management tabs and every action that changes something are password-protected. A fresh bare-metal install auto-generates that password for you. To restrict the dashboard to the local machine, set BIRDNET_LISTEN=127.0.0.1:8502 and reach it via SSH tunnel or VPN. For encryption on the LAN, --tls-mode self-signed serves HTTPS on 8503 without a second daemon. Still don’t port-forward it to the internet — put a reverse proxy with a publicly-trusted certificate, or a VPN, in front. See Remote Access & Security.
How do I find or reset the admin password?
The bare-metal installer auto-generates a strong admin password (username admin) and prints it once in the post-install summary, storing it as CADDY_PWD in /etc/birdnet/birdnet.conf. To set your own, edit that file (or the CADDY_PWD environment variable / .env under Docker) and restart:
sudo nano /etc/birdnet/birdnet.conf # set CADDY_PWD=your-new-password
sudo systemctl restart birdnet-behavior
Editing CADDY_PWD to a new value replaces the admin password at the next restart. A password changed on the Accounts page is kept across restarts instead: the station records which CADDY_PWD it last applied (as a hash, in admin-password.applied beside the database) and does not put an unchanged one back over it. The log says so at warning level on every start while CADDY_PWD is still set. A station upgraded from a release that did not keep that record applies CADDY_PWD once, at its first start, and keeps the record from then on. Remove CADDY_PWD once you have signed in. Sign in as admin — that is the account the dashboard seeds. CADDY_USER is read from the process environment only, so setting it in birdnet.conf does not rename it (it does work under Docker). Clearing CADDY_PWD leaves /admin open to anyone who can reach the dashboard.
What happens to my records if the SD card dies?
Without offsite backups, they go with it. The weekly snapshot the station takes
automatically lives in backups/ beside the database, on the same card — it
protects against a corrupt page or a bad import, not against the card itself,
which in a box on a fence post is a when.
Set OFFSITE_BACKUP=s3 or OFFSITE_BACKUP=sftp (plus OFFSITE_PASSPHRASE) and
each weekly snapshot is encrypted on the station and uploaded to an object store
or an SSH host. Encryption is not optional and not configurable: your database
is a log of what is around your house and when you are there, and neither a
bucket provider nor an SFTP host should hold the key.
The passphrase is the only thing that opens those files. It is not stored
anywhere, not derivable from the backup, and not known to anyone else — write it
down somewhere that is not this station. Restoring is
birdnet-behavior --decrypt-backup <file> --out <path>.
See Backups & Recovery.
Why did turning on repeat confirmation change nothing?
Almost certainly because the analysis windows do not overlap. The filter asks how many of the windows within six seconds heard the same species — and with no overlap, six seconds is two 3-second windows, so “20% of them” rounds up to one, which every detection already satisfies.
Set BIRDNET_OVERLAP as well. birdnet-behavior --doctor reports which side of
that line your station is on, the dropdown on the settings page prints the
overlap each level needs, and the daemon logs a warning at startup when the
level it was given cannot reject anything. See
Tuning.
Do I need anything special for behavioral analytics?
No. The DuckDB engine behind the deeper behavioral views — activity sessions, species retention, next-species prediction, year-on-year trends — is built into every release and on by default. The installer runs the service with --analytics-db and Docker compose sets BIRDNET_ANALYTICS_DB, so there is no separate build, flag, or image to pick. (From source it is a default Cargo feature — --no-default-features leaves it out. On a very low-RAM board you can turn it off by passing an empty path, --analytics-db "" in the unit or BIRDNET_ANALYTICS_DB: "" in a compose override — removing the flag only falls back to <database>.duckdb; see Troubleshooting.)
Can I use it commercially?
No. BirdNet-Behavior is licensed CC BY-NC-SA 4.0, matching upstream BirdNET and BirdNET-Pi — non-commercial use only. See Credits & License.
How do I update?
- Docker:
docker compose pull && docker compose up -d. - Bare metal:
sudo bash install.sh update(or re-run the installer). The Check for Updates button on/admin/system— andGET /admin/update/check— only tells you whether a newer release exists; nothing in the UI applies it. - Your database, recordings and cached model are preserved across updates.
It’s not detecting anything — what now?
Almost always an audio-source problem. Run birdnet-behavior --doctor (as the account the service runs as; or open /admin/doctor behind the admin login), confirm a source is set and reachable, and check the level meter on Audio & Microphones. The Troubleshooting guide has step-by-step recipes.
Glossary
Plain-English definitions for the terms you’ll meet around the dashboard.
Analysis window — the three seconds of audio the model looks at in one go. A 15-second recording is five windows, or more if they overlap.
Banding code — the four-letter shorthand ornithologists use for a species (e.g. NOCA = Northern Cardinal, AMRO = American Robin). BirdNet-Behavior shows it in each species’ colored avatar.
BirdNET / BirdNET+ — the neural network that identifies birds from sound, developed by the Cornell Lab of Ornithology. This app runs BirdNET V2.4 by default (the network BirdNET-Pi runs) and can run the “BirdNET+ V3.0” preview instead; see Installation.
Co-occurrence — how often two species are heard in the same five-minute block of the same day, as a share of the blocks in which either was heard (their overlap, from 0 to 1). Two birds that are merely common are not a strong pair; two that are nearly always heard together are. It is association, not a test against chance. See Behavioral Analytics.
Confidence — the model’s certainty in an identification, from 0 to 1 (shown as a percentage). A detection is only logged if its confidence clears the threshold.
Dawn chorus — the burst of bird song around sunrise. The circadian polar plot shows when each species is most vocal across the day.
Detection — one identification event: a species, a time, a confidence, and (usually) a short audio clip.
Life list — a birder’s running list of every species they’ve ever recorded, each counted once. See Species & the Life List.
Mel spectrogram — the visual “fingerprint” of a sound (frequency over time) that the model actually looks at. The live spectrogram shows it in real time.
Offsite backup — a copy of the database somewhere that is not the station’s own SD card, encrypted before it leaves. The weekly snapshot in backups/ protects against a corrupt page; only an offsite copy protects against the card. See Backups & Recovery.
Phenology — the timing of seasonal events. Migration phenology is when migratory species arrive and depart through the year — the ridgeline chart on the Migration tab of Patterns.
Quarantine — a holding queue for borderline rare-bird detections, so a human can approve or reject them before they join your life list. See the review queue.
Repeat confirmation — a filter that records a species only when enough of the analysis windows around it heard the same thing. A real bird sings across several; an artefact usually fires once. Needs overlapping windows to mean anything. See Tuning.
RTSP — a streaming protocol many IP cameras use. BirdNet-Behavior can listen to an RTSP stream’s audio as if it were a microphone.
Sensitivity — a BirdNET parameter (0.5–1.5) that makes the model more eager or more conservative. See Tuning.
SF threshold (species-frequency) — a filter that uses your location and the week of the year to down-weight species that shouldn’t plausibly be present. See Tuning.
SNR (signal-to-noise ratio) — how loud the bird is relative to background noise, in decibels (dB). Higher is cleaner. Shown on the Audio level meters.
Streamgraph — a flowing, stacked area chart (used for activity over time) drawn from a centered baseline so each band’s thickness shows that species’ share.
Resident vs. migrant — a resident species is present year-round; a migrant only passes through seasonally. Behavioral analytics classifies which is which from your data.
Troubleshooting
The one troubleshooting guide, organised by symptom rather than by cause: find the section that matches what you saw, then work its checks top to bottom. Read it before opening an issue; if nothing here helps, open a bug report with the doctor’s output (below).
Two things the commands on this page assume
Bare metal. The service runs as the account that ran sudo bash install.sh — there is no birdnet user — and keeps its data in ~/BirdNet-Behavior of that account. Set two shell variables once and the commands below work for any account:
SVC=$(systemctl show -p User --value birdnet-behavior) # the account the service runs as
DATA=$(getent passwd "$SVC" | cut -d: -f6)/BirdNet-Behavior # the installer's data directory
Docker. Run docker compose … from the folder holding your docker-compose.yml. The data directory inside the container is /data.
install.sh below means the installer script. If you installed with the one-liner you do not have a copy; fetch one first:
curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh -o install.sh
Start here: run the doctor
sudo -u "$SVC" birdnet-behavior --doctor # bare metal
docker compose exec birdnet birdnet-behavior --doctor # Docker
It prints a one-screen report covering CPU, configuration, audio reachability, the model file, database integrity, disk space, tool dependencies and network — and every problem comes with a concrete suggested fix. Exit code: 0 = all good, 1 = at least one warning (the station runs, with features degraded), 2 = at least one error (it will not work until fixed).
- For monitoring scripts, the same checks as one line of JSON:
birdnet-behavior --doctor-json. - With a browser and no shell, sign in and open
/admin/doctor: the same report, the JSON at/admin/doctor.json, and a support bundle to download (/admin/support-bundle). birdnet-behavior --doctor --fixfirst recreates any configured directory that is missing (the recordings folder, the image cache) — the commonest cause of “the service runs but nothing is recorded”, for example after a tmpfs reset — and then runs the report.birdnet-behavior --support-bundle [PATH]writes the same bundle from the command line: the report (JSON and text), the version, a redacted copy of the configuration and the last 2000 journal lines in one.tar.gz. Passwords, tokens and URL credentials are masked, and the station’sLATITUDEandLONGITUDEare shown to one decimal place (about 11 km), both in the configuration section and wherever the journal printed them. Read the archive before posting it publicly all the same.
Service won’t start
sudo journalctl -u birdnet-behavior -n 100 --no-pager
sudo systemctl status birdnet-behavior
| What the log says | What it means and what to do |
|---|---|
config not found: /etc/birdnet/birdnet.conf | Run sudo bash install.sh reinstall, which writes it. Do not copy .env.example there: that is the Docker environment file (BIRDNET_* keys), while birdnet.conf uses unprefixed keys (CONFIDENCE=, RECS_DIR=, …) and no prefix mapping is applied. |
Failed to set up mount namespacing: /tmp/birdnet-stream: No such file or directory (exit 226/NAMESPACE) | An older unit listed the tmpfs stream dir in ReadWritePaths=, which conflicts with PrivateTmp=. Upgrade, or run sudo bash install.sh repair to rewrite the unit. |
database recovery failed | Run birdnet-behavior --check-db; restore from $DATA/backups/ if the corruption is real (see Database errors). |
failed to install Ctrl+C handler / SIGTERM handler | Probably a non-Unix or sandboxed environment without signal support. |
address already in use | Another service is on port 8502. Change the bind address with BIRDNET_LISTEN=0.0.0.0:<port> (the binary reads --listen / BIRDNET_LISTEN; BIRDNET_PORT is only the Docker host-port mapping) or stop the other service. |
permission denied reading /etc/birdnet/birdnet.conf | The config is 0640 root:<the service account's group> because it holds secrets. sudo bash install.sh repair restores the ownership and permissions. |
another instance is running on this data directory (…/birdnet.lock is locked …) | A previous process still holds the data directory — a restart overlapping a slow shutdown, or the binary started by hand beside the unit. The new process waits 30 s for it (BNB_INSTANCE_LOCK_GRACE_SECS to wait longer) and then refuses rather than open the same files. sudo systemctl stop birdnet-behavior, check pgrep -a birdnet-behavior, and start once. |
For most “won’t start” cases — a stale unit, wrong permissions, a missing directory — the installer’s repair fixes ownership and permissions, rewrites the unit and restarts, without touching your data:
sudo bash install.sh repair
Docker: the container exits immediately.
docker compose logs --tail=200 birdnet
docker compose run --rm birdnet birdnet-behavior --doctor # the doctor in a one-shot container
When a model download fails, the entrypoint lists the common causes: no internet in the container, the model’s host unreachable, or the volume out of disk. See Model not found.
Web UI not reachable
The dashboard binds to all interfaces by default, so it is reachable from other devices on your LAN at http://<pi-ip>:8502 (find the address with hostname -I). If it is not:
sudo systemctl status birdnet-behavior
ss -tlnp | grep 8502
sudo ufw allow 8502/tcp # Ubuntu / Debian firewall; use firewall-cmd on RHEL / Fedora
ip -4 addr show | awk '/inet / && !/127\.0\.0\.1/ {print $2}' # the address to browse to
curl -sf -o /dev/null -w "%{http_code}\n" http://<host>:8502/api/v2/health # from another machine; expect 200
No row from ss means the daemon never finished starting — see Service won’t start. If you deliberately restricted access, BIRDNET_LISTEN will be 127.0.0.1:8502 (reachable only from the station itself); set it back to 0.0.0.0:8502 for LAN access. Viewing needs no login; /admin*, the Settings management tabs and every action that changes something do — use the auto-generated password from the install summary (the admin password FAQ).
No detections appearing
First rule out the audio source.
Bare metal (ALSA).
arecord -l # list capture devices
# Use the card *id* — the word after "card N:" — not the index:
arecord -D plughw:CARD=PRO,DEV=0 -d 3 /tmp/test.wav && aplay /tmp/test.wav
grep -E '^(ALSA_CARD|RTSP_URL|PIPEWIRE_DEVICE)' /etc/birdnet/birdnet.conf # what the station was told to use
sudo cp /etc/birdnet/birdnet.conf /tmp/birdnet.conf && sudo nano /tmp/birdnet.conf # ALSA_CARD=plughw:CARD=<id>,DEV=0
sudo birdnet-behavior --apply-config /tmp/birdnet.conf # validates, backs up, installs, restarts
Other sources: pactl list short sources and parec --device=<source-name> /tmp/test.raw (Ctrl-C after a few seconds) for PulseAudio / PipeWire; nc -zv <camera-host> 554 for an RTSP camera (the doctor does the same probe). If PipeWire and ALSA are both set, PipeWire takes precedence; a local microphone runs alongside any number of RTSP streams, and the doctor lists every source the daemon will start.
Docker.
docker compose logs birdnet | grep -i 'audio source' # which source was picked up?
grep -E '^BIRDNET_(ALSA|PIPEWIRE|RTSP)' .env # is exactly one set?
docker compose restart birdnet
Docker: no audio at all.
ls -la /dev/snd/ # is the device visible on the host?
docker compose -f docker-compose.yml -f docker-compose.alsa.yml up -d # use the ALSA overlay
docker compose logs -f birdnet
Recordings exist but the database stays empty.
ls -la "$(grep ^RECS_DIR /etc/birdnet/birdnet.conf | cut -d= -f2- | tr -d '"')"
sudo journalctl -u birdnet-behavior --since "1 hour ago" | grep -iE "inference|detection|model"
Look for model failed to load or mel spectrogram errors. The common causes:
- The model file is truncated. The doctor flags a file under 1 MB as suspicious; reinstall it (see Model not found).
--watch-dirdoes not match where recordings are written.- Not enough memory for the model on a small board — see Memory and CPU pressure.
- On a new station, no location is set. That does not stop detection, but without coordinates the occurrence filter is off and every species the model knows stays a candidate, so expect birds that do not belong in your area; set
LATITUDEandLONGITUDE.
Detections exist but confidence is always low. Audio quality is almost always the cause. Check the microphone placement (out of the wind, with a foam windscreen), the ALSA gain (alsamixer -c 1, F4 for Capture), and a recording in a spectrogram viewer such as Audacity — bird calls should be clearly visible in the 1–8 kHz band. Then see Tuning Detection Accuracy.
Birds are heard but a conversation near the microphone leaves a gap. That is the human-sound rule, on by default: a window in which a human class ranks in the model’s top 10 classes, and the windows either side, are not recorded. Set Privacy Rank to 0 in the Detection section of the Settings page (or PRIVACY_RANK=0) to switch it off; see Same audio, same detections.
Model not found
sudo -u "$SVC" ls "$DATA/models/" # bare metal
docker compose exec birdnet ls /data/model/ # Docker
A BirdNET V2.4 station (the default for a new install) has three files there: birdnet-v24.onnx, birdnet-v24_labels.txt and birdnet-v24-range-v2.onnx. A BirdNET+ V3.0 station has the V3.0 model and labels CSV, and the geomodel pair. If they are missing, restore them:
- Bare metal:
sudo bash install.sh repairinstalls whatever the configured classifier is missing (it re-checks V3.0’s files against their SHA-256; V2.4’s are trusted if present and not empty). Or install the V2.4 files yourself:sudo -u "$SVC" env BIRDNET_MODEL_DIR="$DATA/models" birdnet-behavior --install-model birdnet-v24and the same withbirdnet-v24-range-v2. Running it again is cheap: a file that already matches its SHA-256 is not fetched again. - Docker: restart the container; the entrypoint installs what is missing and says what it is doing. The next start resumes a BirdNET+ V3.0 download from the partial file; a V2.4 install starts the file again.
If a download fails, the message names the cause: no internet (the V2.4 files come from huggingface.co and raw.githubusercontent.com, the V3.0 files from this project’s GitHub release with Zenodo as the fallback), a full disk (the model installer wants the file’s size plus 512 MiB free), or a SHA-256 mismatch, which means the bytes were wrong and nothing was kept. To bring your own model, mount it over /data/model and set BIRDNET_SKIP_MODEL_DOWNLOAD=1 (Docker), or BIRDNET_SKIP_MODEL=1 when running the installer.
Database errors
sudo -u "$SVC" birdnet-behavior --check-db
PASSED means the schema and pages are intact. FAILED — corruption detected means restoring from a backup: the station takes a snapshot every 7 days into $DATA/backups/ and keeps the most recent 14, and also takes one at start-up whenever it had to recover the write-ahead log. Backups & Recovery says how to put one in place, and the field runbook’s database section says why the write-ahead log has to be removed first. birdnet-behavior --backup-db makes a snapshot on demand.
Behavioral analytics show “extension required”
On the Analytics page, the Activity Sessions, Species Retention and Next Species cards read “the duckdb-behavioral extension is required…”, and the startup log warns duckdb-behavioral extension not loaded.
Up to v0.9.1 the duckdb-behavioral community extension was compiled for a specific DuckDB version, and DuckDB refuses to load such a build into any other one. From v0.10.0 it is built on DuckDB’s stable C API (abi: "C_STRUCT" in /api/v2/analytics/status) and loads into any engine whose C API is at least the one it was built for, but the community registry still publishes it only under the DuckDB releases it was built for. A copy of the extension is also embedded in every release binary, and that copy is what a station uses when the cache and the registry cannot supply one.
This is non-fatal. Everything that reads SQLite directly — Migration, the Dawn Chorus, the Heatmap, Co-occurrence and the whole Time-series page — keeps working; only the extension-backed sessionize / retention / next-species queries are unavailable. The Analytics status badge says connected without claiming behavioral analytics are active.
Diagnose it in one command. --doctor will not answer this: it deliberately never opens DuckDB (a preflight open could contend with the running service), so it reports whether analytics is configured, not whether the extension loads. Use:
birdnet-behavior --verify-extension
It opens a throwaway DuckDB database, loads the extension the way the station does, prints the engine version, the extension version and what the embedded copy targets, and exits 0 when it loads and non-zero when it does not. To prove the offline guarantee — the one that matters on an air-gapped or metered station — run it with no network:
unshare -rn birdnet-behavior --verify-extension # bare metal: a network namespace with no interfaces
docker run --rm --network none --entrypoint /usr/local/bin/birdnet-behavior \
ghcr.io/tomtom215/birdnet-behavior:latest --verify-extension # the image you run
With no network neither the extension cache nor the community registry can supply either extension, so a pass proves the embedded copies loaded. A station that passes with a network and fails without one has a packaging defect — the embedded extension targets the wrong DuckDB version and the registry has been covering for it — which you cannot repair yourself: open an issue with the output.
What you can do. On a station with network access, birdnet-behavior --refresh-extension force-installs the build the community registry has for the bundled DuckDB version, loads it to check, and exits (it needs --analytics-db or ANALYTICS_DB_PATH set). Upgrading to a newer release, which bundles an engine and extension that were built and pinned together, is the other fix.
Every analytics dashboard is empty, and the logs say nothing
The Analytics and Time-series pages render but show no data, the health endpoint is green, --doctor is happy, and nothing in journalctl -u birdnet-behavior looks like an error. It does not recover on its own — stations have sat like this for days. This is not the extension problem above: there the extension-backed cards degrade and say so; here every dashboard is empty, including the ones that need no extension.
Check the journal for this line:
journalctl -u birdnet-behavior --no-pager | grep -i 'duckdb": Read-only'
# Failed to create directory "/home/pi/.duckdb": Read-only file system
Every dashboard query filters on a look-back window, which reaches DuckDB as detection_date >= CURRENT_DATE - INTERVAL n DAYS. CURRENT_DATE lives in DuckDB’s ICU extension, as does every other way to name the current local date. Before the fix below, ICU was fetched at run time, into $HOME/.duckdb; the shipped systemd unit sets ProtectHome=read-only, so that write failed and every date-ranged query failed with it. The web layer renders a query error as a cached “temporarily unavailable” fragment, which is why nothing surfaced as an error.
Fixed in versions after 0.13.1, two ways over: DuckDB’s extension directory now sits beside the analytics database inside the data directory, and the ICU binary is embedded in the release binary, so nothing is downloaded and $HOME is never touched. Upgrading is the fix, and birdnet-behavior --verify-extension now also runs the date-window query the dashboards open with, so it answers this too (including under unshare -rn / --network none).
If you are stuck on 0.13.1 and cannot upgrade yet, pointing HOME at the data directory — the one place the unit already allows writes — restores analytics at once:
sudo systemctl edit birdnet-behavior
# [Service]
# Environment=HOME=/home/pi/BirdNet-Behavior # your data directory; "pi" is your account
sudo systemctl restart birdnet-behavior
The override is harmless to leave in place after upgrading, and unnecessary.
--doctor reports a quarantined analytics database
The startup log shows “analytics database is unusable; quarantining it and rebuilding from SQLite”, and --doctor warns about a ….duckdb.corrupt.<timestamp> file.
No detections were lost, and nothing needs doing. The DuckDB analytics store holds nothing but a copy of rows that live in SQLite, so when the file cannot be read — a bad block on the SD card, a half-written file after a power cut, a DuckDB version change — the station moves it aside and rebuilds it from SQLite on that same start. Delete the quarantined file once you are satisfied nothing else is wrong. If it keeps happening, the storage is the suspect: check dmesg for I/O errors and consider replacing the card.
Memory and CPU pressure on small hardware
Symptoms: kernel OOM-killer messages in dmesg, container restarts in a loop, inference latency spikes, detections that lag minutes behind the audio. Mitigations, in order of effectiveness:
- Enable ZRAM. The bare-metal installer sets it up on hosts with 2 GB of RAM or less during
installandreinstall(notrepair);SKIP_ZRAM=1opts out. To run it again:sudo bash install.sh reinstall. - Reduce overlap. Analysis windows advance by the window length minus
OVERLAP, so the inference work per recording iswindow ÷ (window − overlap)times the work at no overlap. With BirdNET V2.4’s 3 s windows,OVERLAP=1.5is twice the work ofOVERLAP=0.0, and2.0is three times. - Use the smaller model. BirdNET V2.4 (the default for a new install) is a 54 MB file; the BirdNET+ V3.0 preview is 541 MB. This page does not give a memory figure for V2.4, because none has been measured.
- Disable analytics. Analytics is compiled into every release binary and on by default: with no
--analytics-dbthe daemon still opens<database>.duckdbbeside the SQLite file, so removing the flag does not turn it off. Pass an empty value instead. On bare metal change the unit’s--analytics-db <path>to--analytics-db ""(sudo systemctl edit birdnet-behavior, then restart); in Docker add a compose override that setsBIRDNET_ANALYTICS_DB: ""(the compose file sets it underenvironment:, so.envalone cannot blank it). The daemon logsDuckDB analytics disabled via empty --analytics-dbon start. A--no-default-featuresbuild leaves the engine out entirely. DuckDB roughly doubles RAM use during sync.
Notifications never arrive
apprise -t "test" -b "from birdnet" <YOUR_APPRISE_URL> # the Apprise tool, independent of this station
sudo -u "$SVC" birdnet-behavior --doctor 2>&1 | grep -i apprise
The Send Test Push Notification button under Settings → Alerts uses the values saved on the Settings page, so a passing test means live detections will notify too. Common gotchas:
BIRDNET_NOTIFY_CONFIDENCEdefaults to0.8; lower it to see anything during testing.BIRDNET_NOTIFY_TRIGGERdefaults toeach.new-species-dailynotifies on the first detection of each species per day, andnew-speciesonly while a species has fewer than 5 detections this week; useeachwhile debugging.- The Apprise URL must be URL-encoded if it contains special characters.
Non-Latin species names show as boxes
The dashboard ships all its Latin fonts self-hosted, but non-Latin scripts (CJK, Devanagari, …) rely on the client device’s system fonts. On a desktop or phone these are present already; on an always-on Pi kiosk, install a system CJK font:
sudo apt-get install -y fonts-noto-cjk
Nothing here fits
Run these and attach the output to your issue; it answers most of the questions a maintainer would otherwise have to ask:
birdnet-behavior --version
sudo -u "$SVC" birdnet-behavior --doctor
uname -a; free -m; df -h
sudo journalctl -u birdnet-behavior -n 200 --no-pager
docker --version; docker compose version; docker compose logs --tail=200 birdnet # Docker only
SUPPORT.md says which channel to use, and the architecture notes on the risk register and the diagnostics design explain why the doctor checks what it does.
Field deployment runbook
Practical guidance for running BirdNet-Behavior unattended on a single piece of hardware, in the field, for months at a time, with no operator on site to push a button when something goes wrong.
This is the document you read once before deploying and once again the month before the next field season. It is intentionally opinionated — the defaults pick the safe option at every fork.
Contents
- Hardware checklist
- Power and thermals
- Storage planning
- Networking
- System hardening
- Time synchronisation
- Watchdog and process supervision
- Database and backup policy
- Remote diagnostics and monitoring
- Update strategy
- Pre-flight checklist
- Recovery runbook
1. Hardware checklist
Recommended baseline for unattended deployments:
| Component | Recommended | Notes |
|---|---|---|
| Compute | Raspberry Pi 5 4 GB, or Pi 4 4 GB | Pi 4, 5 and 400 are the supported boards; a Pi Zero 2 W is not a supported board (no test is recorded) and is tight on memory |
| Storage | 64 GB endurance-class microSD (Pi) or SSD on USB | Consumer SD cards fail after ~6 months of WAL churn |
| Audio | USB condenser mic with weatherproof housing | Avoid Pi GPIO mics — they’re noisy near the SoC |
| Power | Dedicated 5V/3A PSU + UPS HAT or LiFePO4 + MPPT | Mains glitches are a top failure cause |
| Enclosure | IP65 or better | Vent for thermals; route audio in via gland |
| Network | Wired Ethernet preferred; 4G dongle otherwise | Wi-Fi mesh works but is the second-most-common fault |
The bare-metal installer’s --doctor pass will tell you if any of the
sanity checks fail (CPU cores, disk space, audio device, etc.) before
you ever start the daemon.
2. Power and thermals
- UPS or battery + charge controller. A clean shutdown beats a corrupted database. The Pi 5 PSU draws ~5 W at idle; a 10000 mAh battery rides through a 15-minute mains glitch easily.
- Thermal headroom. Stick a passive heatsink on the Pi 5 SoC; in
sealed enclosures add a 40 mm 5 V fan with a temperature switch. The
Prometheus
/api/v2/metricsendpoint exportsbirdnet_cpu_countand you can scrapevcgencmd measure_tempin parallel. - Solar deployments. Size for 3× average daily watt-hours to
ride out cloudy weeks. Use a battery monitor (e.g. INA219 over
I²C) and stop the service via
systemctl stop birdnet-behaviorwhen battery drops below 30 %.
3. Storage planning
| Workload | Daily volume (typical) |
|---|---|
Detection rows in birds.db | ~0.6 MB per 1 000 detections (see below) |
| Extracted detection clips (WAV) | ~1–10 MB per clip × per-species cap |
| Raw rolling recordings (tmpfs) | 0 on disk (configured to /tmp/birdnet-stream) |
Backups (~/BirdNet-Behavior/backups/) | Up to 14 × birds.db size |
How big birds.db actually gets. Measured on a synthetic three-year station
— 3 285 000 rows (3 000 detections/day), 180 species, the shipped schema and all
sixteen shipped indexes, ANALYZE run:
| Measure | Size |
|---|---|
| Total file | 1.83 GB |
| Per detection, all in | ~557 bytes |
Of which: the detections table | 481 MB (26.3 %) |
| Of which: indexes | 1 352 MB (73.7 %) |
Two things follow. Budget from the detection rate, not from calendar time — a station hearing 8 000/day reaches the same size in a bit over a year. And note that most of the file, and most of the write amplification on your SD card, is index rather than data; the backup ring multiplies whatever that number is by up to fourteen.
Set the disk policy explicitly:
# /etc/birdnet/birdnet.conf
RECS_DIR=/data/recordings
MAX_FILES_SPECIES=100 # cap clips per species
DISK_PURGE_THRESHOLD=85 # autopurge starts at 85 %
The bundled disk manager supervises two directories, once a minute, with the retention each one needs:
- the recordings directory, holding your extracted clips beside
birds.db. These are your data, so they are never removed by age. Only the disk-full backstop touches them: once usage exceedsDISK_PURGE_THRESHOLDit removes a tenth of them, taken from the most-recorded species first (lowest confidence, then oldest, within a species) and never taking a species belowPURGE_SPECIES_FLOORclips (5 unless you set it;0removes the floor), so the one clip of a rare bird outlives the thousandth of a common one. It skips any file the database has marked locked (the 🔒 Lock action on Recordings, or on a Today row). The locked set is re-read every cycle, so locking a clip takes effect immediately — no restart needed. - the raw capture directory (
--watch-dir, typically the RAM-backed/tmp/birdnet-stream), which the detector reads and never needs again. It is drained continuously by age and by a total-size ceiling (STREAM_RETENTION_SECS,STREAM_MAX_MB) so the tmpfs self-empties. SetRAW_AUDIO_KEEP_EVERY=Nto copy one aged segment in N (in capture order) into<recordings>/rawbefore the drain removes it: that is the audio a season can be re-analysed from under a new model, and without it only the audio that already triggered a detection survives. Budget it — a 15 s segment at 48 kHz mono is about 1.4 MB, so1is roughly 8 GB a day per source and10about 0.8 GB — and know that the disk-full purge takes kept raw audio before it takes any clip.
When inference falls behind. The daemon measures its own queue — the raw
segments waiting for analysis — and publishes it as birdnet_analysis_queue_depth
and on /api/v2/health as analysis_queue_depth. While more than 40 are
waiting (ten minutes of 15 s segments from one source), or while the board is
at or above 80 °C or reports throttling, it analyses one segment in two, in
capture order, and counts the rest in birdnet_segments_shed_total by reason.
The queue then drains at twice the rate, the skipped audio is counted rather
than lost, and the backlog station-health condition tells you it happened.
Before this, the same situation resolved to the stream drain deleting the
oldest unanalysed audio with nothing to show for it. If the condition keeps
returning, the board cannot keep up with what it is asked: fewer sources, a
longer segment, or a faster board.
Two further limits are enforced from the database on the daily maintenance tick, and both leave the detection rows intact — only the audio is reclaimed, so your counts, species lists and analytics are unaffected:
MAX_FILES_SPECIES— keep the newest N clips per species.CLIP_RETENTION_DAYS— reclaim audio older than N days. Off by default (0= keep forever); set it in Settings → General → System & Display (“Keep Clip Audio”) or the config file if you want a rolling window.
Locked clips are exempt from both. A reclaimed clip keeps its filename and gains the date its audio was removed, so the record of what was captured survives even though the file does not.
All of these can be set from the web UI — you never have to edit this file to
change them. A command-line flag or BIRDNET_* variable wins over the UI,
which wins over the config file.
Maintenance runs on wall-clock time, not uptime. The daily jobs (integrity check, session prune, species cap) and the weekly backup + reclaim record their completion in the database, so a station that reboots often still runs them — an overdue job fires shortly after the next boot rather than restarting its timer.
Use endurance-class SD cards. A standard A1 card will write out ~3000 P/E cycles per cell; WAL on a busy station can wear that out in a year. SanDisk High Endurance and Samsung PRO Endurance are tested choices. SSD-on-USB is dramatically better if your power budget allows it.
4. Networking
The daemon assumes the network may be down or expensive, and no network failure can slow or block the detection pipeline — every integration is dispatched off the detection path. Verified behaviours:
- BirdWeather uploads — store-and-forward. A post that fails after its
in-flight retries is parked in the local database (
outbound_queue) and replayed oldest-first when the network returns: batches of 25 with 200 ms spacing so a returning uplink isn’t slammed, per-entry backoff 1 min → 1 h, bounded to 5 000 entries / 48 attempts so a months-long outage can’t fill the disk. Nothing is lost to a flaky link; see §9 for the queue-depth gauge and the self-hosted-ingest override. - Apprise notifications — best-effort, dispatched off the detection path; non-delivery is logged but never blocks detection. Not queued by design — a look-now alert replayed hours later is noise.
- MQTT publisher — fire-and-forget per detection, opening a fresh bounded-timeout connection; dispatched off the detection path so an offline broker never stalls detection. Not queued by design (live telemetry).
- Wikipedia image cache — populated lazily; works fine with no network at all.
- Auto-update check — daily, non-blocking; failure logged at
debug. The check reads bounded response bodies (it cannot be made to exhaust memory by a hostile or misbehaving endpoint). - Heartbeat URL — fire-and-forget; no retry, by design (the monitoring side is the one that should care if it didn’t arrive).
For cellular deployments, consider scheduling cron pppd up/down
windows so the modem only powers up for the daily upload batch.
5. System hardening
The installer’s systemd unit (install.sh) ships hardened by default:
Type=notify+WatchdogSec=120— systemd restarts the daemon if it stops callingsd_notify(WATCHDOG=1)for 2 min.ProtectSystem=strict,ProtectHome=read-only, explicitReadWritePaths— the daemon can only write where it needs to.PrivateTmp=yes,NoNewPrivileges=yes,LockPersonality=yes,MemoryDenyWriteExecute=yes,RestrictRealtime=yes,RestrictNamespaces=yes.SystemCallFilter=@system-serviceminus the@privileged @resources @mount @debug @cpu-emulation @obsolete @reboot @swap @raw-io @clock @modulegroups, withsched_setaffinityallowed back: ONNX Runtime pins its threads with it, and a denied call kills the service rather than failing.MemoryHigh=768M,MemoryMax=1G,TasksMax=512,LimitNOFILE=65536,LimitNPROC=256— bounded resource ceilings; runaway processes can’t take down the host. The FP32 model is loaded into anonymous memory, which a limit cannot reclaim — measured at about 575 MB idle and 650 MB while analysing, with the bundled DuckDB analytics on — so the 1 GiB ceiling is headroom for analytics queries, not slack. The Docker compose files use the same limit. On a 512 MB board physical RAM plus zram binds first.OOMPolicy=stop— under memory pressure the unit stops cleanly instead of being killed mid-write.
If you customise the unit, run systemd-analyze security birdnet-behavior.service to check the score (the shipped unit is
graded around 1.5–2.0, “OK” on the systemd scale).
6. Time synchronisation
Every detection row carries a wall-clock timestamp. If the clock jumps backwards, analytics break.
- Enable
systemd-timesyncd(default on Raspberry Pi OS) orchrony(preferred for cellular deployments — handles long offline windows better). - The unit is ordered after
time-sync.target, so on a host runningsystemd-timesyncdorchronythe sync normally lands first. It is ordering only — nothingWants=that target, so the service starts even if the clock never syncs. That is why the daemon fails open on clock-dependent gating (see below) rather than assuming a good clock. - For deployments with no network at all, fit a battery-backed RTC
module (DS3231 on I²C) and load
rtc-ds1307at boot.
Daylight saving, and the hour that happens twice
Detections are stored the way BirdNET-Pi stored them: a local Date and a
local Time, with no offset recorded alongside. That is fine for 8 759 hours
of the year and needs one paragraph of thought for the other one.
Autumn, when the clocks go back. One local hour repeats. A station on
Europe/Berlin records 02:00–02:59 twice on the last Sunday in October; one on
America/New_York records 01:00–01:59 twice on the first Sunday in November. In
the database those two hours are indistinguishable — they carry the same local
Date and Time — so:
- Anything that reads the history as a timeline sees that hour’s detections interleaved rather than in order. A dawn-chorus curve is unaffected (the transition is at night); a session or gap analysis spanning that hour is not, and will report a gap or a burst that did not happen.
- Two detections of the same species, at the same local second, at the same
rounded confidence, in both passes, collide on the detections table’s
identity. The second is refused and lost. It is counted — see
birdnet_detection_write_failures_totalunder Remote diagnostics — so the station can tell you it happened rather than leaving it to the journal. The clip is kept either way, under a-2suffix.
Spring, when the clocks go forward. One local hour does not exist. Nothing is lost; the history simply has an hour-shaped hole in it, on one night, and any “quietest hour” or “longest gap” statistic computed across that night will find it.
If any of that matters to your analysis — a phenology study, a
year-over-year comparison across the transition, published data — run the
station on UTC (sudo timedatectl set-timezone UTC) and accept that every
hour-of-day chart is then in UTC rather than in your local morning. That is the
only configuration in which no local hour ever repeats or vanishes. --doctor
reports the station’s offset and how the recording window is interpreted, so
you can check which convention a station is on without guessing.
7. Watchdog and process supervision
This is the workhorse of unattended operation:
- The daemon calls
sd_notify(READY=1)once the HTTP server is listening. - A background tokio task calls
sd_notify(WATCHDOG=1)everyWATCHDOG_USEC / 2(usually 60 s). - If the daemon hangs (livelock, GPU deadlock, ML thread stuck), the
pings stop. systemd kills it after
WatchdogSecand restarts viaRestart=always. StartLimitIntervalSec=0— the rate limit is off and the unit never gives up. A tight restart loop is prevented by backing off instead (RestartSec=10,RestartSteps=10,RestartMaxDelaySec=300), so a permanently-broken install retries quietly every five minutes for ever and recovers by itself the moment its cause is fixed. An unattended box never parks itself infailedwaiting for a visit.ExecStartPrerunsbirdnet-behavior --doctor-gate(or plain--doctorfor a binary too old to have the gate, such as a rolled-back.prev): the full doctor report, in the journal on every start. It exits 2, blocking startup, only for a failure the station cannot run past (an unreadable configuration file, an invalid listen address, an unwritable database directory, an unusable HTTPS setup), so the journal shows what is broken instead of just “service kept restarting”. Any other failure is reported and the service starts; runbirdnet-behavior --doctorfor the plain exit codes.
Smoke-test the watchdog after installation:
# Pause the daemon to simulate a hang. systemd should kill it after 2 min.
sudo kill -STOP "$(pidof birdnet-behavior)"
journalctl -u birdnet-behavior -f
# Expect: "Watchdog timeout" then "Restarting" within 120 s.
Per-source gain and quiet window (manual hardware checks)
Each audio source (Settings → Capture, /station/capture; the old
/admin/audio redirects there) carries an optional quiet window and, in the
audio_sources table, a software gain (gain_db). Like every other
per-source setting (device, sample rate, RTSP transport), these are read when
the capture subsystem starts, so restart the service after editing them for
the change to take effect:
sudo systemctl restart birdnet-behavior
Gain (gain_db). At unity gain (0 dB) a local microphone records through
arecord (the lightest path). A non-zero gain routes that source through
ffmpeg instead so the gain can actually be applied. There is currently no
control for gain_db in the web UI — the per-source edit form exposes the
label, device, quiet window, pipeline toggles and equaliser only, and the value
is merely shown in the source’s detail line — so write the column directly to
exercise this path, and verify on real hardware:
# Set a source's gain to e.g. +12 dB (no UI control yet — write the column), restart, then:
sqlite3 ~/BirdNet-Behavior/birds.db "UPDATE audio_sources SET gain_db = 12 WHERE label = '<label>';"
ps -ww -C ffmpeg -o args= | grep -- '-af volume'
# USB mic with gain → ffmpeg: "-f alsa -i <device> … -af volume=12.00dB"
# Set the gain back to 0, restart, and confirm the mic is back on arecord:
pgrep -a arecord # present again at unity gain
# RTSP / PipeWire sources are always ffmpeg; the filter appears only with gain.
A captured clip from the gained source should be audibly louder (or quieter for a negative dB cut) than at unity gain.
Quiet window (schedule_quiet). Set a window that currently includes the
time of day (each end is a clock time such as 22:00 or a solar anchor such as
sunset+30 / sunrise-15), in the station’s local time — a quiet window means the
operator’s night, so it is evaluated against the local clock, as is a
fixed:HH:MM-HH:MM recording window. (A solar schedule is not: sunrise and
sunset are absolute instants, so that gate is evaluated in UTC and needs no
timezone thought at all.) Within ~2 reconcile ticks (≈ a few seconds) the
source’s capture subprocess stops:
journalctl -u birdnet-behavior -f
# Expect: "recording paused (outside schedule or in quiet window)" for the source,
# and the birdnet_audio_source_up{source=<id>} gauge drops to 0 on /api/v2/metrics.
# Move the window so "now" is outside it, restart, and confirm the subprocess
# is respawned and the gauge returns to 1.
While the system clock looks unsynced (no RTC yet at boot, NTP not ready) the quiet window is not enforced — capture fails open exactly as the schedule does, so a bogus boot-time date can never silence a source.
Multi-source resilience (USB + several RTSP at once)
You can run one or more local microphones (USB/ALSA or PipeWire) and any
number of RTSP streams simultaneously — set them up in Settings → Capture, or
seed them from the config file with ALSA_CARDS (;-separated) and
RTSP_URLS (,-separated). Each source is captured by its own subprocess and
recordings carry a per-source tag (local/MIC_n and RTSP_n, or the source
row’s id), so detections, recordings, and the
birdnet_audio_source_up{source=…} gauge stay distinct per source.
Every source is supervised independently — the central property for an unattended field station with flaky cameras:
- One source failing never disturbs the others. A dead subprocess (camera rebooted, USB mic unplugged, network blip) is restarted with capped exponential backoff (2 s → 4 s → … → 60 s, then every 60 s forever — a source down for an hour is still recording when it comes back on hour two). The other sources keep recording and the detection pipeline keeps running throughout.
- Silent stalls are caught, not just crashes. An RTSP camera (or a USB mic wedged after a re-enumeration) whose process stays alive but stops delivering audio is detected by watching each source’s newest segment: no fresh segment for several segment-durations (floor 2 min) and the source is restarted, exactly like a crash. Stall detection fails open while the clock is unsynced (segment mtimes aren’t trustworthy before NTP).
- A network outage never blocks detection. BirdWeather, Apprise, MQTT, email, and heartbeat are all dispatched off the detection path, so a dead broker or an offline uplink for days slows none of them down — detections keep landing in the local database and you reconcile from there.
Verify per-source isolation on real hardware before sealing the unit:
journalctl -u birdnet-behavior -f
# Unplug ONE RTSP camera (or one USB mic). Expect, for that source only:
# "audio source DOWN … still trying to restart" and the
# birdnet_audio_source_up{source=<id>} gauge for THAT id drops to 0,
# while every other source's gauge stays 1 and detections keep flowing.
# Plug it back in: "audio source up" and the gauge returns to 1 on its own.
8. Database and backup policy
The daemon runs a scheduled maintenance task in the background (no operator action needed):
- Daily
PRAGMA integrity_check. Failure is logged atERRORand appears in the/api/v2/healthendpoint. - Weekly WAL checkpoint +
PRAGMA incremental_vacuum, a MiB at a time, to return the pages that deletes freed. NotVACUUM: that rewrote the whole file through the temp directory under one lock — three times the file size written, staged in the unit’s memory-charged/tmp, and a detection lost todatabase is lockedwhile it ran. A database created before this mode existed is converted once, at the next start, with the copy staged beside the file; the log says so. - Weekly backup snapshot taken just before the reclaim, written to
${DATA_DIR}/backups/. The 14 most recent are kept; older ones are pruned automatically. - Manual snapshot any time:
birdnet-behavior --backup-db.
Every one of those lives on the station’s own storage. For a box that is
expected to run a season unattended, set OFFSITE_BACKUP so each weekly
snapshot is encrypted and uploaded to an object store or an SSH host as well —
it rides the same weekly job, keeps the newest OFFSITE_KEEP (default 8), and
never touches the local rotation. Restore with
birdnet-behavior --decrypt-backup <file> --out <path>. See
Backups & Recovery.
Restore from the most recent good backup (only do this after the daemon is stopped):
sudo systemctl stop birdnet-behavior
ls -lt ~/BirdNet-Behavior/backups/ | head -3
# Delete the write-ahead log FIRST. This step is not optional — see below.
rm -f ~/BirdNet-Behavior/birds.db-wal ~/BirdNet-Behavior/birds.db-shm
cp ~/BirdNet-Behavior/backups/birds.db.backup.<timestamp> ~/BirdNet-Behavior/birds.db
# Confirm you got what you asked for before starting the daemon again.
sqlite3 ~/BirdNet-Behavior/birds.db 'PRAGMA integrity_check; SELECT COUNT(*) FROM detections;'
sudo systemctl start birdnet-behavior
Why the
rm -fcomes first. The database runs in WAL mode, sobirds.db-walholds committed transactions that are not yet inbirds.db. Copying a backup over the main file while that log is still beside it does not replace the database: on the next open SQLite replays the log and you are back on the data you were trying to discard.This was reproduced rather than reasoned about. A backup taken after a
VACUUM(1 000 rows) was copied over a database that had since grown to 9 000 rows plus a table the backup did not contain, with the log left uncheckpointed. Reopening reportedintegrity_check: okand 9 000 rows — the restore silently did nothing, and said so in green. A second run that happened to leavebirds.db-shmin place restored correctly, so it fails intermittently, which is worse than failing every time. Deleting both sidecars removes the ambiguity.Restoring from the web UI (Settings → Data → Backups & recovery) removes them for you and runs the integrity check itself; this is the manual equivalent.
9. Remote diagnostics and monitoring
Once the unit is sealed and shipped, the loop is:
-
Heartbeat URL (
HEARTBEAT_URL=) — set to a free https://healthchecks.io check. The daemon pings it every five minutes, whether or not it has heard a bird, and pings once immediately at startup so a station clears its alarm within seconds of coming back from a power cut. You get an email if the pings stop for longer than your grace period (recommend: 15 min — three missed pings, so one dropped packet on a 4G bearer does not page you).This signal answers exactly one question: is the box still there? It deliberately says nothing about whether the station is hearing anything — a December night at 55° N is sixteen hours long, and a heartbeat that only fired on a detection could not tell a quiet night from a dead Pi. “Has it stopped detecting?” is
birdnet_detection_silence_secondsand the detection deadman (DEADMAN_HOURS); “is it degrading?” is the station-health alerts. Three signals, three meanings; wire up all three. -
/api/v2/health— pulled by your monitoring (Uptime Kuma, Healthchecks remote endpoints, custom cron) over the LAN or via a port-forward. -
/api/v2/metrics— Prometheus text format. Scrape with Prometheus, Grafana Agent, or VictoriaMetrics. Key series:birdnet_uptime_seconds,birdnet_detections_stored,birdnet_process_resident_memory_bytes,birdnet_species_distinct, and the two field-health gauges below.Also worth watching for an unattended station:
birdnet_noise_floor_dbfsandbirdnet_noise_floor_drift_db, per capture source. A microphone that fails outright is caught bybirdnet_audio_source_upand by the deadman; one that merely goes deaf — water in the capsule, a web across the port, a connector loosened by a year of thermal cycling — keeps both of those green and shows up only as fewer detections, which is what autumn looks like too. The background does not stop when the birds do, so a sustained drop in one source’s noise floor against its own history points at the equipment. The same figures are on the Settings Health tab. No threshold ships with them on purpose — set one from what your own site does over a few weeks.Worth an alert of its own:
birdnet_detection_write_failures_totalcounts detections the model produced and the database refused. It should stay at zero. A non-zero value means the station heard something and could not keep it — a full or read-only disk, a locked database, or the repeated daylight-saving hour described in Time synchronisation. Alert on any increase; the journal line beside it names the species and timestamp. -
Detection deadman — the end-to-end “is it actually detecting?” check that no per-component gauge can answer. The station measures how long ago the last detection landed and exports it as
birdnet_detection_silence_seconds(alsodetection_silence_secson/api/v2/health, and the “last detection” figure on Settings → Health,/station— the old/systemredirects there). AfterDEADMAN_HOURSof silence (default 24;0disables; also--deadman-hours/BIRDNET_DEADMAN_HOURS) it logs a loud warning and — when Apprise is configured — pushes one alert per quiet episode, with a recovery notice when detections resume. Stations in sparse habitats should raise the threshold; a Grafana alert on the gauge gives finer control. -
Store-and-forward uploads —
BirdWeatherposts that fail while the uplink is down are parked in the local database and replayed automatically when connectivity returns (oldest first — the upstream record’s sequence matches what happened in the field — in capped batches, exponential backoff up to 1 h, bounded queue). Watchbirdnet_outbound_queue_depth{kind="birdweather"}— a depth that only grows means the uplink (or token) has been broken for a while. Settings → Health (/station) shows a queued-uploads figure whenever the queue is non-empty. MQTT and Apprise/email are deliberately NOT queued: they are live telemetry and look-now alerts, and replaying them hours later is worse than dropping them — the local database is always the ground truth.Self-hosted ingest (sensitive species). Research programmes that must keep observation locations under their own governance — rare or endangered species where a public community map is a poaching risk — can redirect the same upload pipeline (including the offline queue and ordered replay) at their own endpoint that implements the
BirdWeatherstation API shape:# /etc/birdnet/birdnet.conf BIRDWEATHER_URL=https://ingest.example.org/api/v1(Env equivalent:
BIRDNET_BIRDWEATHER_URL; only the host changes — the/stations/<token>/detectionspath shape is preserved.) The active endpoint is logged at startup so a misdirected station is visible in the first journal lines. -
birdnet-behavior --doctor-json— for monitoring scripts that speak JSON (Home Assistant command sensor, Nagios, Zabbix). Same exit codes as the human-readable mode. The same report is served over HTTP behind the admin login —/admin/doctor(rendered),/admin/doctor.json(the--doctor-jsondocument) and/admin/support-bundle(the redacted archive--support-bundlewrites; it shows the station’s coordinates to one decimal place, about 11 km) — so a station can be diagnosed from a browser without SSH. -
SSH tunnel via Tailscale / ZeroTier / Cloudflare Tunnel — gives you the web UI from anywhere without exposing a port to the open internet. Recommended over plain port-forward.
Station-health alerts
The detection deadman answers “is the station detecting at all?”. Station health answers the questions that leave it quiet — the faults a station keeps detecting straight through:
| Condition | Threshold |
|---|---|
| An audio source down while others keep recording | 15 min continuous |
| Disk full enough that recordings are being purged | ≥ 90 % used |
| CPU at or above the Pi throttling point | ≥ 80 °C |
| Backup or integrity check not completed | > 21 days |
| Clock not set, or not synchronised to a time source | 15 min continuous |
Each alerts once per episode, with a recovery notice when it clears, through the same Apprise notifier as the deadman. Every condition must persist for three consecutive five-minute polls before it fires, so a mic that re-enumerates or a disk that spikes during clip extraction stays silent.
Once per delivered alert. The loud journal line is written when the condition
is first observed; the push is then retried at every five-minute poll until it
actually reaches a destination, so a fault raised while the uplink was down is
still announced when the uplink comes back. A recovery that happens before the
onset alert got out replaces it, so you are never handed a fault that has
already cleared. Alerts about the station are also exempt from
NOTIFY_RATE_PER_MINUTE, which is sized for detections — a deadman crossing its
threshold during a dawn chorus used to lose that race silently. Whatever still
cannot be delivered is counted in birdnet_notifications_dropped_total.
The clock check is the one that catches a silent loss rather than a visible one. A station whose NTP has been unreachable for months keeps recording, keeps detecting, and keeps every gauge green — while filing an entire season under the wrong hours. Nothing else notices, because a plausible-looking date passes every other check the station makes.
It is deliberately silent where it cannot know: in Docker there is no systemd
to ask, and the container’s clock is the host’s, so the condition never fires
and birdnet_clock_synced is absent rather than 0.
A single-source station that goes fully down is deliberately not reported here — the deadman covers that, with better wording, and two notifications for one fault is how a channel gets muted. This check exists for what the deadman structurally cannot see: some sources up, some down.
On by default. Disable with --station-health-alerts false, or
STATION_HEALTH_ALERTS=false in /etc/birdnet/birdnet.conf.
# Confirm it started.
journalctl -u birdnet-behavior | grep 'station-health notifier started'
10. Update strategy
Field-deployment philosophy: don’t auto-update.
- The daemon checks for updates daily (logged at INFO when one is
available) but never applies them automatically. The admin panel’s
Check for Updates button tells you when one exists; apply it with
sudo bash install.sh update. - Test a new release on a bench unit first. Run it for at least 72 h before pushing it to field units.
- When you do push, do it during the species’ low-activity window (e.g. local 14:00 for songbirds) so a downtime blip costs the fewest detections.
install.shkeeps the outgoing binary at/usr/local/bin/birdnet-behavior.prev, so a one-linemvrollback is available if the new build misbehaves. The update prints the exact command. The unit it writes asks for--doctor-gateonly from a binary that has it, so the rolled-back binary starts under it. A unit written by 0.17.0 did not do that: after rolling back from 0.17.0, runsudo bash install.sh repairwith the current installer to rewrite it. When the update added the geomodel to a config that had none, it prints a second rollback command that also takes those lines back out; use that one, because a release from before the geomodel cannot use it.- Rolling back leaves the database at the newer schema. The previous binary logs a warning about it at every start and keeps recording; every release is tested for this (below). Its analytics pages may stop updating, because the analytics copy carries columns it does not know; the detections are unaffected.
- Every release is gated on this path before it is published: the newest
previous releases are installed under systemd, given detections, updated
in place, and rolled back with the printed command
(
installer/test/upgrade-e2e.sh).
11. Pre-flight checklist
Run through this the day you take the unit to the field:
# 1. Hardware and audio.
birdnet-behavior --doctor # ALL green (or warnings only).
# 2. Time sync working.
timedatectl status | grep "System clock synchronized: yes"
# 3. Disk has > 5 GiB free.
df -h /data
# 4. Backup directory exists and is writable.
ls -la ~/BirdNet-Behavior/backups/
# 5. Watchdog smoke test (see § 7).
sudo kill -STOP "$(pidof birdnet-behavior)" && sleep 130 && \
systemctl is-active birdnet-behavior
# 6. Reboot test — comes back clean?
sudo reboot
# After reboot, wait 2 min, then:
systemctl is-active birdnet-behavior # expect "active"
curl -sf http://localhost:8502/api/v2/health | jq .status
# 7. Network failure tolerance.
sudo ip link set wlan0 down
sleep 60
journalctl -u birdnet-behavior --since "1 minute ago" | grep -i error
sudo ip link set wlan0 up
# 8. Heartbeat reaches your dead-man switch.
# Configure HEARTBEAT_URL, restart, then confirm in the dashboard at
# https://healthchecks.io/.
If any of those eight steps fails, fix it on the bench, not in the field.
12. Recovery runbook
Symptom-driven, oldest-known-cause first:
| Symptom | First action |
|---|---|
| Heartbeat stopped | ssh in, systemctl status birdnet-behavior, then journalctl -u … -n 200 |
journalctl shows “Watchdog timeout” | Look 200 lines earlier for the last activity — likely audio device hang |
--doctor reports [ FAIL ] for model | Re-download: rm ~/BirdNet-Behavior/models/*.onnx (bare metal) or rm /data/model/*.onnx (Docker), then restart the service |
| Disk full | birdnet-behavior --doctor will say so; lower MAX_FILES_SPECIES |
| Database integrity check fails | Stop service; restore from ~/BirdNet-Behavior/backups/ |
| Web UI 500s | Check /api/v2/health for status; integrity check most common cause |
| Detection latency growing | OOM throttling — check birdnet_process_resident_memory_bytes over time |
| Service retrying every 5 minutes | Backoff has reached its ceiling; read the ExecStartPre doctor output in the journal — an exit-2 preflight blocks startup |
See also: Troubleshooting for general diagnostics not specific to field deployments.
See also
- Troubleshooting — symptom-organised
problem solving (the repository’s
TROUBLESHOOTING.mdis only a pointer to it) SECURITY.md— disclosure policydocs/architecture/12-risks.md— risk matrixdocs/architecture/14-diagnostics.md— design of the--doctorsystem
Security Hardening Guide
BirdNet-Behavior is designed to be safe by default on a trusted home network, but if you are deploying it somewhere more exposed — a shared flat, a research site with multiple tenants, a campus network, or anywhere reachable from the internet — this guide collects the knobs that matter.
For the project’s threat model and trust boundaries, see
docs/architecture/12-risks.md.
To report a vulnerability, see
SECURITY.md.
TL;DR for an exposed deployment: keep
CADDY_PWDset (a fresh install generates one automatically), restrict the bind to loopback (or a VPN) if you don’t need LAN access, put a reverse proxy with TLS in front for anything off-LAN, leave CORS at its same-origin default, and verify release artifacts before installing.
1. Network exposure
The single most important decision is what can reach the web UI. Viewing the
dashboard requires no login; the /admin panel — which can change settings,
trigger database backups, and update the software — is gated by a
session-cookie sign-in enforced by the binary itself. Treat reachability as
the primary control — and when the station is reachable from beyond the LAN,
turn on private mode, which
puts the dashboard itself behind that sign-in.
-
Default: all interfaces. A bare-metal binary defaults to
--listen 0.0.0.0:8502, so the dashboard is reachable from other devices on the LAN out of the box. The installer auto-generates an admin password (CADDY_PWD) on a fresh install, so/adminis protected by default; the open dashboard exposes only read-only views. -
Restrict to this host: set
BIRDNET_LISTEN=127.0.0.1:8502(env, the config file, or answer “restrict to this device” in the interactive installer) — then reach it remotely with an SSH tunnel (ssh -L 8502:localhost:8502 pi@host) or a VPN. -
Startup guard. When the server binds to a non-loopback address and no
CADDY_PWDis configured (e.g. you cleared it), it logs a prominent warning at startup. If you seeWARN admin web UI is bound to a non-loopback address with NO authentication …in the journal, either set
CADDY_PWD(below) or bind to127.0.0.1. -
Encrypt the LAN traffic.
--tls-mode self-signedbrings HTTPS up on 8503 and generates a local CA to import once;--tls-mode manualserves your own certificate and reloads it when your ACME client renews it. Off by default.--doctorverifies the whole setup before startup does. -
Private mode for anything reachable from outside. A tunnel, a mesh VPN shared with other people, or a port forward makes “anyone who can reach the port” a much larger set than your LAN, and
--listen 127.0.0.1does not help: the tunnel is what connects to it. SetBIRDNET_PRIVATE_MODE=true(config file:PRIVATE_MODE=true) and the whole station — dashboard, read API, live audio, both WebSockets — needs the sign-in. See Private mode below. -
Never port-forward
8502/8503to the internet. Built-in HTTPS encrypts the traffic; it does not add a login lockout or a WAF, and a self-signed certificate carries no publicly-trusted name. Put it behind a reverse proxy (Caddy/nginx) that terminates HTTPS with a real certificate and adds authentication, or use a mesh VPN (Tailscale/WireGuard). See Remote Access & Security for proxy configs.
2. Authentication
Authentication gates the /admin* panel, the Settings management tabs
(/station/capture|alerts|data|settings|access) and every page action that
changes something — viewing the dashboard, the read-only /api/v2/*
endpoints, the WebSockets, and the health check are open to anyone who can
reach the port, unless private mode
is on. A fresh install auto-generates a strong admin
password, so /admin is protected by default; for anything LAN- or
internet-reachable, keep it set (and add TLS off-LAN).
-
Built-in admin sign-in. A fresh install sets
CADDY_PWDautomatically and prints it once in the post-install summary, storing it in/etc/birdnet/birdnet.conf. Sign in asadmin— the account the dashboard seeds./admin*without a session redirects to a/loginform that issues a session cookie; this is not HTTP Basic Auth, socurl -udoes not apply. Change the password any time on the Accounts page, or by editingCADDY_PWDin the config or the environment to a new value (the station records theCADDY_PWDit last applied, so an unchanged one never replaces a password changed on the Accounts page):CADDY_PWD=a-long-random-passwordCADDY_USERis consulted from the process environment only (Docker), not frombirdnet.conf— the systemd unit sets noEnvironmentFile, so on a bare-metal install the sign-in name staysadmin.A signed-in session survives a restart of the station: the signing secret is generated once and kept beside the database as
session.secret(mode 0600), unlessBNB_SESSION_SECRETis set, which takes precedence. RotatingCADDY_PWDin the config signs every session out at the next start; rotating on the accounts page signs every other session out.The form is throttled per client address: five failed attempts inside fifteen minutes and that address is answered
429 Too Many Requests(with aRetry-After) until its oldest failure is a quarter-hour old — the password is not even checked, so a guessing script costs the Pi a map lookup rather than an Argon2 hash per guess. Each refused attempt is in the audit log asauth.login.throttled; a successful sign-in clears the address, and a restart forgives everything. Behind a reverse proxy the address is the visitor’s only if the proxy is trusted (BIRDNET_TRUSTED_PROXIES; there is no command-line flag for it); otherwise every visitor shares the proxy’s bucket, which is the conservative failure. An attempt counts from the moment it arrives, not when its password check finishes, so twenty sent at once still get five checked. A trusted hop can name any visitor it likes, so each hop other than this machine also has a budget of its own — thirty failures a quarter-hour across every address it names — and a LAN device that invents a newX-Forwarded-Forper guess runs out there.CF-Connecting-IPis believed only whencloudflareis inBIRDNET_TRUSTED_PROXIES.This is compatible with the BirdNET-Pi
CADDY_PWDconvention. The password crosses the wire in clear text unless TLS is on — turn on--tls-mode, put a proxy in front, or keep the station on a trusted LAN. ClearingCADDY_PWDleaves/adminopen — and with it every state-changing action on the dashboard (delete a detection, relabel it, set a review verdict, approve or delete a quarantined record, save the onboarding wizard) — to anyone who can reach it. Reading stays open either way.“Anyone who can reach it” means by a local name. With no password, the admin panel answers only when the station is addressed by an IP address,
localhost, a one-word name (birdnet), or a name ending in.local,.lan,.home,.home.arpa,.internalor.localdomain. Any other name gets a page asking for a password to be set first. That closes DNS rebinding: without it, a website anyone in the house visits could point its own name at the Pi and operate the open panel through their browser, because the browser’sOriginandHostwould agree. If you reach an open station through your own domain and want that to keep working, list the name inBIRDNET_ALLOWED_HOSTS— or, better, set a password, which makes the check moot. -
Reverse-proxy auth (recommended for internet exposure): terminate TLS and require a password at the proxy (Caddy
basic_auth, nginxauth_basic), so credentials never cross the wire in clear text. -
WebSocket caveat. The live-detection WebSocket (
/api/v2/ws/detections) and the health endpoint (/api/v2/health) are intentionally exempt from the built-in sign-in layer, because a browser cannot attach credentials to aWebSockethandshake. (They are read-only and outside the gated router in any case.) The live detection stream is therefore readable by anyone who can reach the port. If that matters, gate access at the network layer (VPN / proxy allow-list) rather than relying on app-level auth.
Private mode: everything behind the sign-in
BIRDNET_PRIVATE_MODE=true
BIRDNET_PUBLIC_ACCESS=share,metrics # optional carve-outs
(PRIVATE_MODE=true and PUBLIC_ACCESS=… in birdnet.conf; --private-mode
and --public-access on the command line.)
With private mode on, a visitor with no session gets nothing but the sign-in
form: pages answer a 303 to /login, the API and the WebSockets a 401.
What stays open without a session is exactly:
| Always open | Why |
|---|---|
/login, /logout, /static/*, /favicon.ico | a browser has to be able to render the sign-in form |
/api/v2/health | the systemd watchdog and the container healthcheck read it |
plus whatever BIRDNET_PUBLIC_ACCESS names, comma-separated:
| Carve-out | Opens |
|---|---|
live_audio | /stream, its HLS variant under /api/v2/streams/hls/, and the live spectrogram WebSocket — for a station whose feed is meant to be listened to |
share | the signed /r/<token> links the Share clip button mints, with their audio and spectrogram; not the recordings route behind them |
metrics | /api/v2/metrics, for a Prometheus scraper that has no cookie |
Anything else — the detection history, the recordings, the feeds, the
detection WebSocket — is behind the sign-in with no way to open it. Viewer
accounts (the /station/access tab) can see everything a signed-in admin
can, and change nothing, so a private station can still be shared with the
household.
Private mode needs a password. A private station with no admin password
(neither CADDY_PWD nor one set in the setup wizard or on the accounts page)
answers 503 to everything but the sign-in and the health probe — the admin
panel included — and does not fall back to the open station, because that
is the one thing it was asked not to be. The startup log says so at ERROR, the page says so, and
--doctor reports it under Private mode. An unknown name in
BIRDNET_PUBLIC_ACCESS is reported and skipped (the station starts, with
that surface closed); --doctor reports it too.
3. Cross-origin requests (CORS)
By default the API allows no cross-origin reads — it emits no
Access-Control-Allow-Origin header. The station’s own UI is served from the
same origin it calls, so this is all most deployments need, and it prevents a
malicious website you visit from reading your station’s API over the LAN.
If you front the API from a different origin (a separate dashboard host), allow it explicitly:
# comma-separated list of allowed origins
BIRDNET_CORS_ALLOWED_ORIGINS=https://dashboard.example.com,https://lab.example.com
* is not supported: the station logs an error and ignores that entry (it
would let any website read the API), and with no valid origin left the policy is
the default, same-origin only.
State-changing requests are additionally protected by a stateless CSRF guard regardless of this setting.
4. Privacy
The station listens to a live microphone, so audio handling is privacy-relevant.
- Human-voice filter. Set
BIRDNET_PRIVACY_THRESHOLD(0.0–1.0;0.02is a usual start, and lower suppresses more) to suppress analysis windows in which the model’s confidence for a human class reaches the value, and their neighbours, and any detection whose saved clip would reach such a window. While it is on, clips are not extended into neighbouring segments.0.0disables it. It binds independently of the detection threshold. - Recording retention. Extracted detection clips accumulate on disk; cap
them with
BIRDNET_MAX_FILES_PER_SPECIESand rely on the disk manager’s purge threshold. Audio you never want persisted should be filtered at the source. - What is stored: detection rows (species, time, confidence, location if configured), short extracted audio clips, and spectrogram images. No raw continuous audio is retained beyond the rolling capture buffer.
Credentials as mounted files
Every outbound credential — the notification URLs (whose bot token is inside
the URL), the BirdWeather token, the MQTT password, the heartbeat URL whose path
is the secret — can be supplied as the contents of a file rather than as an
environment variable, using the BIRDNET_<KEY>_FILE convention Docker and
Kubernetes both use. That keeps it out of docker inspect, out of
/proc/<pid>/environ, and out of the settings table (and so out of every backup
taken from the database). See
Notifications
for the list and the compose snippet.
5. A bad configuration edit cannot take the station down
The daemon validates its configuration at startup. An invalid setting (a
latitude outside ±90, a malformed RECORDING_SCHEDULE, an unsupported
AUDIO_FORMAT) used to make it refuse to start, and since validation ran in
the new process after systemd had stopped the old one, a typo made over SSH
became a restart loop with no web UI and no way back. Now every successful
start keeps a copy of the file it ran on as birdnet.conf.last-good; a start
whose file has errors runs on that copy and reports config_reverted on
/api/v2/health and the station page; a start with errors and no copy runs
web-only on the file as it is and reports config_rejected, so the diagnostics
are reachable and show the errors.
A revert never loosens access. If the file on disk turns private mode on or
sets CADDY_PWD, the station keeps those even while running on the older
copy, and when both files are private it opens only the PUBLIC_ACCESS
carve-outs both of them name. A typo in the same edit cannot undo a lock-down.
Change the file the safe way, which validates before anything is installed:
sudo cp /etc/birdnet/birdnet.conf /tmp/birdnet.conf && sudo nano /tmp/birdnet.conf
sudo birdnet-behavior --apply-config /tmp/birdnet.conf # refuses a file with errors
birdnet-behavior --doctor # the same checks, on demand
birdnet-behavior --doctor-json # exit code: 0 ok, 1 warn, 2 error
The systemd unit runs the doctor as an ExecStartPre gate
(--doctor-gate) for the journal’s sake. It blocks the start only for a
failure the station cannot run past — an unreadable configuration file, an
invalid listen address, an unwritable database directory, an unusable HTTPS
setup. Every other failure, a configuration error included, is reported there
and the start goes ahead; /admin/doctor shows the same report.
6. Data & backups
-
Database backups. Take a hot backup from Settings → Data (
/station/data#backups) orbirdnet-behavior --backup-db; the periodic maintenance task also rotates backups beside the database. Those are on the same card as the database, so they cover a corrupt page and not a dead card. -
Get a copy off the device automatically. Set
OFFSITE_BACKUP=s3orOFFSITE_BACKUP=sftpand each weekly snapshot is encrypted on the station — argon2id + ChaCha20-Poly1305, not optional — and uploaded. The passphrase is the only thing that opens it, is stored nowhere, and is not recoverable: write it down somewhere that is not this station.Give the station its own credentials, scoped to the one prefix it writes to. For S3 that is
PutObject,ListBucketandDeleteObject, nothing more; the station never reads a backup back. For SFTP it is a dedicated key on a dedicated account, and host key checking that cannot be turned off —yes, oraccept-newfor the first connection on a network you control.Secrets have no command-line flags on purpose: an argument is visible in
psto every user on the box and is copied into the journal byExecStart=. Put them in the config file (mode0600, owned by the service user) or the unit’s environment.--doctorreports what is missing, and checks the SSH key’s mode — OpenSSH refuses a key others can read, and says so only on its own stderr.See Backups & Recovery.
-
Integrity & recovery. On startup the database is integrity-checked; a corrupt database that cannot be recovered from a backup is quarantined aside and a fresh one is started so the station keeps recording (the quarantined file is preserved for offline recovery). Run
--check-dbto verify on demand. -
Restore from the Backups page or by stopping the service and replacing the database file with a known-good copy.
7. Verify what you install
Every release ships signed provenance and a bill of materials:
-
SLSA build provenance — verify an archive came from this repository’s release workflow:
gh attestation verify --repo tomtom215/BirdNet-Behavior \ birdnet-behavior-<version>-aarch64-unknown-linux-gnu.tar.gz -
Checksums —
sha256sum -c SHA256SUMS --ignore-missing. -
SBOM — a CycloneDX 1.5 SBOM (JSON + XML) is attached to each release for dependency/vulnerability auditing.
Prefer the one-line installer or Docker image from the official repository, and
pin a specific version tag in production rather than latest.
8. Host hardening (what the unit already ships)
The installer also writes a journald drop-in,
/etc/systemd/journald.conf.d/birdnet-behavior.conf, with
Storage=persistent and SystemMaxUse=200M. On a default Raspberry Pi OS
the journal is volatile, so a power cut or a watchdog restart erased the
evidence of what caused it; persistent and bounded, it keeps more than a
year of the station’s logging in 200 MB (the two per-file INFO lines that
were most of the volume are DEBUG now) and cannot fill the card. The setting
is host-wide, as journald’s configuration is; install.sh uninstall removes
the drop-in and leaves the journal itself.
The installed systemd unit runs as a non-root user, gates startup on the
doctor, and already carries the hardening a drop-in would usually add. From
install.sh’s unit template:
[Service]
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=<data dir> <config dir> /run /var/log
PrivateTmp=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
UMask=0027
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @mount @debug @cpu-emulation @obsolete @reboot @swap @raw-io @clock @module
SystemCallFilter=sched_setaffinity
DevicePolicy=closed
SupplementaryGroups=audio
Do not paste an older “recommended drop-in” over this. The data directory lives
under the service user’s home (~/BirdNet-Behavior), which is why the unit
relies on ProtectHome=read-only plus its own ReadWritePaths= line to reach
it — a drop-in setting ProtectHome=true or ReadWritePaths=/var/lib/birdnet-behavior
names a stricter mode and a directory the installer never creates. If you
tighten further with systemctl edit birdnet-behavior, verify the service
still starts and can reach its audio device and data directory afterwards. Pair with a host firewall (ufw/nftables) that
only opens the ports you actually use.
Checklist for an exposed deployment
- Restrict the bind to
127.0.0.1(+ SSH/VPN) if you don’t need LAN access; for off-LAN access, put a TLS reverse proxy in front. - Turn on
--tls-mode self-signedif the dashboard is reachable by anyone else on the network. - Keep
CADDY_PWDset (a fresh install generates one) — or use proxy/VPN auth. Don’t clear it on a non-loopback bind. - Turn on
BIRDNET_PRIVATE_MODEif the station is reachable from beyond your own LAN (a tunnel, a port forward, a VPN other people are on), and carve out only what you mean to publish withBIRDNET_PUBLIC_ACCESS. - Set
OFFSITE_BACKUP(withOFFSITE_PASSPHRASE) so a dead SD card is not the end of the records — and store the passphrase somewhere other than the station. - Leave CORS at its same-origin default unless you genuinely need a second origin.
- Set
BIRDNET_PRIVACY_THRESHOLDif voices may be captured. - Back up the database off the device and test a restore.
- Verify release provenance/checksums before installing; pin a version.
- Consider the systemd sandboxing drop-in and a host firewall.
On-device hardware acceptance test
docs/RELEASE_PLAN.md § 5 lists what a green CI matrix cannot establish:
Real Raspberry Pi hardware. Throughput, thermals and live capture from a physical microphone remain unmeasured. qemu-user proved the aarch64 binary executes and serves; it is not the board.
scripts/hardware-test.sh is how that gets established. It drives the installed
systemd service on real hardware — installing from the published release exactly
as an operator would, measuring what the board actually does, then deliberately
breaking the station to prove each documented recovery path is real rather than
merely coded.
Every result is recorded with the command that produced it, in the same spirit as the release plan’s method note: a green gate only proves what it actually executes.
1. What you need
| Requirement | Why |
|---|---|
| Raspberry Pi 4 or 5, 64-bit OS | There is no armv7 binary |
| Raspberry Pi OS Trixie / Debian 13 (glibc ≥ 2.39) | Bookworm’s glibc 2.36 cannot run the native binary — use Docker there instead |
| A USB microphone, plugged in | Half the phases are about live capture |
| ~6 GiB free disk | the binary (about 100 MB), the model (about 85 MB for BirdNET V2.4, the default; about 560 MB for the V3.0 preview) + recordings + test ballast |
| Network (for the install only) | Everything after the fetch runs offline |
An ordinary login account with sudo | Do not run the harness as root — the station’s data directory is derived from $HOME |
A wired SSH session is easier than wifi, because one phase deliberately drops the network. If you are on wifi over SSH, read § 4 first — the harness arms a systemd recovery timer before it drops the link, so the box comes back even if your session dies with it.
2. Running it
# On the Pi, as your normal user (not root, not sudo).
# Run it inside tmux: the netloss phase drops the network and the reboot phase
# reboots the board — either will kill a bare SSH session and the run with it.
tmux new -s hwtest
curl -fsSL https://raw.githubusercontent.com/tomtom215/BirdNet-Behavior/main/scripts/hardware-test.sh \
-o hardware-test.sh
chmod +x hardware-test.sh
./hardware-test.sh
(If tmux is not installed: sudo apt install -y tmux. The harness detects an
SSH session outside tmux and warns before it starts. Reattach after a drop with
tmux attach -t hwtest.)
The full suite takes roughly 45–70 minutes, most of it the install (the classifier download over wifi) and the 10-minute performance sampling window. That estimate dates from when the install fetched the 541 MB V3.0 model; the default classifier is now the smaller V2.4, and the figure has not been re-measured.
Useful variants:
./hardware-test.sh --list # show the phase ids
./hardware-test.sh --safe # skip everything destructive
./hardware-test.sh --phase perf # re-run one phase
./hardware-test.sh --skip install # run everything except one phase
./hardware-test.sh --resume # continue after the reboot phase
BIRDNET_PERF_MINUTES=30 ./hardware-test.sh --phase perf # longer thermal soak
Testing a binary you installed yourself? Use
--skip install. The install phase fetches the published release and would overwrite it, so everything after that point would describe a different build than the one you meant to test. Skipped phases are recorded as skipped in the state file, so the--resumeafter the reboot does not quietly run them either.
The reboot phase reboots the board. When it comes back, reconnect and run
./hardware-test.sh --resume — the harness remembers which phases already
passed and picks up the post-reboot assertions.
Results land in ./birdnet-hwtest-<timestamp>/:
| File | Contents |
|---|---|
report.md | The summary to read and to share |
results.jsonl | One JSON object per check, machine-readable |
env.txt | Full board/OS/peripheral inventory |
install.log | Complete installer output |
perf-samples.csv | Temperature / RSS / inference-count time series |
journal-<phase>.log | Service journal captured at each fault injection |
doctor.txt, verify-extension.txt | Diagnostic output verbatim |
Exit status is non-zero if any check failed.
3. What each phase proves
| Phase | Establishes |
|---|---|
env | Board model, glibc floor, capture device present, undervoltage/throttle register, NTP sync, free disk |
install | The published one-liner completes on a clean box, and how long it takes |
verify | Binary runs, doctor’s verdict, service active, health endpoint, --verify-extension under unshare -rn, systemd hardening actually applied |
capture | A real capture subprocess is running, segments reach the tmpfs watch dir, recordings persist, and the mic is not delivering digital silence |
detect | The reference Eurasian Magpie recording, pushed through the watch directory, produces a new stored Pica pica detection — the total must rise, so a magpie the station recorded last week cannot satisfy it. Real inference on this board, with whichever classifier the install chose |
pipeline | Accounts for every segment between capture and inference over a ten-minute window — the segments capture wrote against the files the daemon actually opened — and names the stage that drops them |
perf | Mean decode-to-prediction latency per stored detection (a measurement, not a verdict — see below), peak SoC temperature, throttle register under load, RSS against the unit’s MemoryHigh=768M |
web | Every documented endpoint answers; the dashboard is reachable from the LAN; whether /admin is open without a password |
watchdog | SIGSTOP the daemon → systemd’s watchdog kills and restarts it, and the station serves again |
unplug | Pull the mic → gauge drops to 0, daemon survives; plug it back → recovers unattended via capped backoff |
netloss | 60 s with no network → daemon stays up, loopback still serves, no panic, connectivity returns |
diskfull | Filesystem pushed past the 95 % purge threshold and below doctor’s 1 GiB floor → --doctor still permits startup, the service restarts under that pressure, disk manager reacts, daemon survives, no panic |
dbcorrupt | 8 KiB of random bytes over the SQLite header → --check-db detects it, the station recovers on restart, and ends healthy |
duckdb | Same against the derived analytics store → it rebuilds rather than refusing to start |
reboot | Cold reboot → service auto-starts, dashboard serves, capture resumes |
report | Writes the summary report.md into the output directory |
The headline numbers
- Does the station keep up? This comes from
pipeline, notperf: it counts the segments capture wrote against the files the daemon actually opened, over a ten-minute window. That is a throughput measurement and it works on a silent site. - Mean decode-to-prediction latency (
perf) is reported as a measurement, never as a verdict, and it is observed once per stored detection — not once per analysed chunk. Its sample count therefore tracks how many birds called, so a quiet site yields few or no observations however fast the board is; the harness says so rather than inventing a number. Do not divide it by elapsed time to infer coverage. - Peak SoC temperature. A Pi 4 begins soft-throttling at 80 °C. The harness warns above that, because a bench result at 20 °C ambient is not a July result in a sealed enclosure.
4. Safety
The destructive phases are genuinely destructive; each one arms its undo before it breaks anything.
-
Disk fill. A ballast file is sized to satisfy both thresholds at once — at least 96 % used and under 1 GiB free, since the purge fires on a percentage while doctor grades in absolute bytes — while never consuming the last 200 MiB. It is removed by an
EXIT/INT/TERMtrap, and the phase restarts the service afterwards if its own restart test left the unit parked. If the filesystem cannot reach the target safely, the phase skips. -
Ctrl-C stops the run. Worth stating because it did not always: a bash trap handler that merely returns does not end the script — execution resumes where the signal landed. Signals now get a handler that cleans up and then exits 130, so Ctrl-C frees the ballast, resumes any
SIGSTOPped process, and stops, rather than cleaning up and carrying on into the next fault injection. -
Network drop. A
systemd-run --on-active=75timer restores connectivity before the link goes down, so a dropped SSH session recovers on its own. On a NetworkManager box the harness usesnmcli networking offrather thanip link set … down, which NetworkManager would immediately undo — that would make the phase a no-op that reports success. -
Database corruption. The harness takes its own copy of
birds.db(and the WAL/SHM sidecars) into the results directory first, and restores it if the station does not recover on its own. It also runs--backup-dbso the product’s own recovery path has something to restore from. -
Watchdog. If the test fails, the
SIGSTOPped process is sentSIGCONTby the exit trap rather than left frozen.
Run --safe to skip all of it.
5. Reporting results
report.md is written to be pasted directly into an issue or a release note.
It carries the board, OS, glibc, version under test, the pass/fail/warn/skip
tally, the measured numbers, and a per-phase finding list with a
“Failures to triage” section at the end.
When a phase fails, the matching journal-<phase>.log holds the service
journal from that moment — start there.
Multi-stream detection handling (design note)
This page is a design note, not a feature description. Two small parts of it
exist; the rest is a proposal, and nothing in the settings, the flags or the
database reads the names it proposes (DEDUP_WINDOW_SECS and dup_of appear in
no source file). Today, a bird heard by two streams is two detection rows, both
counted everywhere, and nothing collapses them.
What exists, and what to look at for it:
| Part | State | Where |
|---|---|---|
Each detection records its source (the RTSP stream id, or local) | shipped | the Source column, migration 18; the source parameter on Search |
| “Also heard by” card on a detection’s page | shipped | the other sources that heard the same species within 30 seconds of the segment start, at most 8, nearest first |
| Per-source include/exclude of counts | not built | section 2 below |
Time-window collapse (DEDUP_WINDOW_SECS, dup_of) | not built | section 3 below |
The rest of the page is the reasoning for handling a bird that several audio streams (RTSP mics / cameras + the on-board mic) can hear at once.
Status
- Stage 1 —
Sourcetagging: shipped. Every detection is tagged with its source (the RTSP stream id, e.g.cam1, orlocalfor the on-board mic). Non-destructive; historical / imported rows stayNULL. See migration 18 andDetectionRecord.source. - Stage 2 — the operator-facing UX: partly shipped. The corroboration card
(§1 below, “Also heard by”) is live on the detection-detail page. The
per-source analytics filter (§2) and the time-window collapse (§3) remain
unimplemented; they are deliberately routed through the UI design pass (see
the “per-source / multi-stream UI” item in
docs/DESIGN_BRIEF.md) so they ship as one coherent experience rather than bolted-on widgets.
The problem
Multiple streams capture independently, so the same bird heard by two streams
becomes two detection rows. Two things make the timestamps not line up across
streams: (1) Time is the segment start, and streams segment on unaligned
boundaries (up to a full segment apart); (2) RTSP/network clock drift on
top. The true instant of a detection is recoverable, though:
instant = parse(Date + " " + Time) + chunk_offset_secs
(chunk_offset_secs is the sub-second moment within the segment.) So a robust
cross-stream comparison is possible — but it must be window-based, never an
exact-timestamp match.
Design principle
Non-destructive. Raw detections are never merged or deleted. Every
multi-stream behaviour is a reversible view or filter over Stage 1’s
Source column. Exports (CSV / BirdDB.txt) and the detection-detail page always
show raw rows.
Recommended approach — ordered by value for a non-technical operator
Build in this order. Each stage is independently useful and lower-risk than the next.
1. Corroboration (primary — non-destructive, always-on, zero config)
On a detection (detail page, optionally a feed badge), show the other sources that heard the same species within a generous window of the true instant:
Also heard by: cam2 · 06:00:01 · 0.91 · cam3 · 05:59:58 · 0.88
This reframes “duplicates” as corroboration — multiple mics agreeing is a stronger signal a detection is real, which is exactly the reassurance a non-technical owner wants. It hides nothing, needs no settings, and is a pure read-time query.
Sketch: concurrent_detections_from_other_sources(conn, date, time, sci_name, exclude_source, window_secs) → rows where Sci_Name = ?, Source IS NOT NULL AND Source <> exclude_source, and ABS(julianday(Date||' '||Time) - julianday(?d||' '||?t)) * 86400 <= window (a generous default window, ~30 s,
since segment starts are unaligned). No schema or write-path change.
2. Deterministic per-source analytics filter (opt-in, comprehensible)
Let the operator include/exclude a whole source from counts/analytics with
simple checkboxes (“count detections from: ☑ cam1 ☑ cam2 ☐ cam3-backup”). This
is the safe, non-technical answer to “a redundant backup mic doubles my counts”:
deterministic, reversible, and — unlike a fuzzy time window — it can never hide a
spatially distinct bird. Implementation: a stored set of excluded sources +
AND (Source IS NULL OR Source NOT IN (…)) on the count/analytics queries (the
indexed Source column from Stage 1 makes this cheap).
3. Time-window collapse (advanced, off by default — last resort)
Only for stations the operator declares co-located/redundant. Flag a detection
as a duplicate of an earlier one (same species, different source, true
instants within DEDUP_WINDOW_SECS); keep the row, set a nullable dup_of
(rowid of the canonical = highest-confidence in the group). Analytics add AND dup_of IS NULL when enabled. DEDUP_WINDOW_SECS defaults to 0 (off).
This is last on purpose: it is the only option that can hide a real detection if the operator misjudges their topology, and the one a non-technical user can least reason about. Ship it only if (1) and (2) prove insufficient, and only with the design pass’s UX.
Why not lead with auto-collapse
For a mixed topology the system cannot distinguish “two mics, one bird” from “two mics, two birds in different trees.” A fuzzy auto-collapse that guesses wrong silently drops real detections — the worst failure for a detection appliance, and the fastest way to lose a non-technical owner’s trust. Leading with corroboration (adds information) and deterministic source filtering (hides nothing the operator didn’t explicitly choose) gives the same “cleaner numbers” outcome without the data-trust risk.
Same-source is never collapsed
All cross-stream logic requires different Source. Two detections from the
same source within the window are either one stream’s adjacent chunks (already
keyed apart by chunk_offset_secs, migration 11) or genuinely separate birds —
never folded.
Route the UI through the design session
The per-source legend/filter, the corroboration display, and the (advanced)
collapse toggle are the “per-source / multi-stream UI” work item in
docs/DESIGN_BRIEF.md. Design them there as one coherent surface,
then implement against the agreed design.
Running BirdNet-Behavior on macOS (Apple Silicon)
Status: preview — pending hardware verification. The workspace is process-based for audio capture and
ortships anaarch64-apple-darwinONNX Runtime prebuilt, so BirdNet-Behavior is expected to build and run on Apple Silicon (M-series) Macs. The macOS-specific paths below (the avfoundation microphone source, the launchd service, the release build) have not yet been confirmed on real hardware or amacos-14CI run. Treat this as a setup guide to validate, not a support guarantee.
Quick install
install.sh is OS-aware: on macOS it sets up a per-user launchd LaunchAgent
(not systemd), so run it without sudo:
curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh | bash
When the release it resolves publishes an aarch64-apple-darwin build (v0.17.1
does), this downloads and installs it, installs the classifier (BirdNET V2.4 and
its range filter by default, --classifier birdnet-v3 for the V3.0 preview; see
Installation),
writes a starter config under ~/Library/Application Support/birdnet-behavior/,
and writes a ready-to-load LaunchAgent. If that asset is missing for the version
it resolved, it offers to brew install the dependencies and prints the
one-time source-build steps below. A Homebrew formula is planned so this becomes
brew install. Uninstall any time with
./uninstall.sh
(--purge to remove data too).
What works the same as Linux
File/--watch-dir analysis, RTSP capture (via ffmpeg), the web UI, SQLite,
DuckDB analytics, and ML inference are all platform-independent — they shell
out to ffmpeg/arecord or use cross-platform crates.
Prerequisites
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Build + runtime deps (cmake builds the bundled libduckdb; ffmpeg captures audio)
brew install cmake ffmpeg
Build from source
git clone https://github.com/tomtom215/BirdNet-Behavior.git
cd BirdNet-Behavior
cargo build --release # analytics is a default feature; ONNX Runtime downloads on first build
The binary lands at target/release/birdnet-behavior. It does not download a
model on its own: install one into a folder you own and point the config at it
(MODEL_PATH, LABELS_PATH and, for the occurrence filter, METADATA_MODEL_PATH;
the command prints the paths):
BIRDNET_MODEL_DIR=~/models target/release/birdnet-behavior --install-model birdnet-v24
BIRDNET_MODEL_DIR=~/models target/release/birdnet-behavior --install-model birdnet-v24-range-v2
One-shot verification
packaging/macos/verify-macos.sh
runs the whole from-source check in one go — toolchain/dependency probe, release build,
--doctor preflight, a real --web-only boot with a GET / check, and
avfoundation device enumeration — then prints the manual steps (microphone/TCC
consent, the launchd LaunchAgent) that need a human at the machine:
bash packaging/macos/verify-macos.sh # full build + boot + report
SKIP_BUILD=1 bash packaging/macos/verify-macos.sh # reuse an existing build
It installs nothing and changes no system settings. Paste its output back when reporting macOS results.
Live microphone capture (avfoundation)
On macOS the microphone is captured through ffmpeg’s avfoundation input
rather than ALSA’s arecord (which is Linux-only); the daemon selects this
automatically. List your audio input devices and their indices with:
ffmpeg -f avfoundation -list_devices true -i ""
Set the audio device index (default 0) as the microphone device in your
config. The first capture will trigger a Microphone access prompt — grant
it under System Settings → Privacy & Security → Microphone. A headless
background service cannot obtain this consent, which is why the launchd unit
below is a per-user LaunchAgent, not a system daemon.
Run
# UI only (screenshots / kiosk), no capture:
target/release/birdnet-behavior -c birdnet.conf --web-only --listen 127.0.0.1:8502
# Full station with mic + analytics:
target/release/birdnet-behavior -c birdnet.conf --analytics-db analytics.duckdb
Run as a service (launchd)
A ready-to-edit LaunchAgent lives at
packaging/macos/com.tomtom215.birdnet-behavior.plist.
Copy it to ~/Library/LaunchAgents/, edit the paths and station coordinates,
then launchctl load -w it. See the comments in the plist for details.
What is not used on macOS
These Linux-only mechanisms are skipped or not applicable; none block the build, but be aware:
- tmpfs transient-audio mount — uses Linux
mount/umount; on macOS leave the tmpfs option off (a RAM disk viahdiutil/diskutilis the macOS equivalent and is not wired up). - systemd service control, the watchdog, and
sd_notify— alreadycfg-gated to Linux; the admin “restart service” controls are inert. /procsystem metrics — some host metrics on the System page are Linux-specific.install.sh— is OS-aware: on macOS it installs a per-user launchd LaunchAgent instead of a systemd unit (see Quick install above); run it withoutsudo.
Homebrew (planned)
A Homebrew formula/tap that pulls the aarch64-apple-darwin release tarball is
the intended end-user install path. It is not published yet; build from source
in the meantime. The release pipeline now builds the aarch64-apple-darwin
target so a formula has an artifact to reference once the target is verified.
Full Configuration Reference
This page mirrors the project’s two canonical sources of truth verbatim:
- the documented
.env.exampletemplate — every environment variable, and - the binary’s
--helpoutput — every CLI flag.
The .env.example block below is embedded straight from the repository at build time, so it can’t drift from the code. For how settings layer together (a CLI flag or its BIRDNET_* variable > the Settings page > birdnet.conf > the built-in default) and the web-UI-only settings, see Configuration.
.env.example names every setting by its environment variable (BIRDNET_…). The same settings have a different name in birdnet.conf, and a station configured by file wants that name, so the next section lists the file keys that neither block below spells out.
birdnet.conf keys by their file names
The file keys are the ones birdnet-behavior --doctor knows (crates/birdnet-core/src/config/known_keys.rs); a key outside that list is reported at startup with the nearest real name. For most of them the environment variable is BIRDNET_ plus the key, and the flag is the lower-case, hyphenated key; where it is not, the table says so. Defaults and meanings are the flag’s own, from the CLI reference below. The settings that are only in .env.example or the Configuration table are not repeated here.
| File key | Flag · environment variable | Default | What it does |
|---|---|---|---|
PIPEWIRE_DEVICE | --pipewire-device · BIRDNET_PIPEWIRE_DEVICE | — | PipeWire or PulseAudio source (default for the system default). Takes precedence over ALSA_CARD when both are set |
SEGMENT_DURATION | --segment-duration · BIRDNET_SEGMENT_DURATION | 15 | Seconds per recording segment, from 3 to 600 (--doctor checks the config file against that; the Settings page and PUT /api/v2/settings refuse a value outside it, and a fraction, a unit or a negative number). BirdNET-Pi’s RECORDING_LENGTH is read as this value when SEGMENT_DURATION is absent or blank (an explicit SEGMENT_DURATION wins), so a birdnet.conf carried over from BirdNET-Pi keeps its chunk length |
NIGHT_INHIBIT | --night-inhibit · BIRDNET_NIGHT_INHIBIT | off | Do not record at night; needs LATITUDE and LONGITUDE |
PRE_SUNRISE_OFFSET | --pre-sunrise-offset · BIRDNET_PRE_SUNRISE_OFFSET | unset: TWILIGHT_OFFSET | Minutes before sunrise at which a solar schedule starts recording |
POST_SUNSET_OFFSET | --post-sunset-offset · BIRDNET_POST_SUNSET_OFFSET | unset: TWILIGHT_OFFSET | Minutes after sunset at which a solar schedule stops (TWILIGHT_OFFSET itself defaults to 30) |
AUDIOFMT | --audio-format · BIRDNET_AUDIO_FORMAT | wav | BirdNET-Pi’s name for the clip format (wav, mp3, flac, ogg); AUDIO_FORMAT is the other accepted spelling, and AUDIOFMT wins if both are set. Non-WAV needs ffmpeg or sox |
CLIP_TARGET_LUFS | BIRDNET_CLIP_TARGET_LUFS (no flag) | off | Normalise saved clips to this integrated loudness, e.g. -18 (−40 to −6; a value outside that is ignored, not clamped); see Audio & Microphones |
CLIP_PEAK_CEILING_DBFS | BIRDNET_CLIP_PEAK_CEILING_DBFS (no flag) | -1 | The sample peak a normalised clip may not pass, in dBFS (−12 to 0) |
CUSTOM_IMAGE_DIR | --custom-image-dir · BIRDNET_CUSTOM_IMAGE_DIR | — | A folder of your own species pictures, one file per species, named {lowercase_scientific_name_with_underscores} plus .jpg, .jpeg, .png or .webp (the code tries them in that order), checked before the image cache. It replaces a species’ picture; it is not a single dashboard image |
INFO_SITE | --info-site · BIRDNET_INFO_SITE | ebird | Species info links: ebird, allaboutbirds or none |
SITENAME | --site-name · BIRDNET_SITENAME | BirdNet-Behavior | The name in page titles and the header |
STATION_NAME | — (the Station Name field on the Settings page) | — | The station’s own name: the Home Assistant discovery name when --site-name is not given, and the default location name in the eBird export |
LANG | — | — | Range-checked as a language code but not read as the language: the language key is DATABASE_LANG (--lang, BIRDNET_LANG; default en) |
MODEL | — | — | BirdNET-Pi’s model name; it is only echoed in the start-up log. The model that runs is MODEL_PATH |
LOG_LEVEL | --log-level · BIRDNET_LOG_LEVEL | the built-in filter | trace, debug, info, warn, error or off; RUST_LOG overrides it |
LOG_MODULES | --log-modules · BIRDNET_LOG_MODULES | — | Per-subsystem levels, e.g. audio=debug,web=warn (subsystems: audio, detection, web, db, integrations, analytics) |
WEEKLY_REPORT_SCHEDULE | --weekly-report-schedule · BIRDNET_WEEKLY_REPORT_SCHEDULE | monday | The weekday the weekly report is sent through Apprise, or disabled |
APPRISE_TRIGGER | --notify-trigger · BIRDNET_NOTIFY_TRIGGER | each | each, new-species (fewer than 5 detections this week), new-species-daily (first of the day) or off; a comma-separated list sends one message when any of them matches (BirdNET-Pi’s three APPRISE_NOTIFY_* switches) |
FULL_DISK | — (the When the disk is full field on the Settings page) | purge | purge or keep: at DISK_PURGE_THRESHOLD, delete the oldest clips, or stop recording and delete nothing. A held recording resumes by itself once usage is 2 points below the threshold (below 93 % for 95 %), or after a restart with a higher threshold. Only the recordings directory takes keep: if --watch-dir is the recordings directory it is ignored, and the start-up log says so |
APPRISE_WATCHLIST | --notify-species-only · BIRDNET_NOTIFY_SPECIES_ONLY | — | Notify only for these species (comma-separated scientific names) |
APPRISE_WATCHLIST_EXCLUDE | --notify-species-exclude · BIRDNET_NOTIFY_SPECIES_EXCLUDE | — | Never notify for these species |
APPRISE_COOLDOWN | — | 300 | Seconds before the same species can notify again |
MQTT_PORT | --mqtt-port · BIRDNET_MQTT_PORT | 1883, or 8883 with MQTT_TLS | Broker port |
MQTT_TLS | --mqtt-tls · BIRDNET_MQTT_TLS | off | Connect over TLS; the certificate is always verified, with no way to skip it |
MQTT_CA_FILE | --mqtt-ca-file · BIRDNET_MQTT_CA_FILE | — | PEM file of extra certificates to trust, for a broker behind a private or self-signed CA |
MQTT_CLIENT_CERT | --mqtt-client-cert · BIRDNET_MQTT_CLIENT_CERT | — | PEM file with this station’s client certificate, for a broker that requires mutual TLS (require_certificate true). Needs MQTT_CLIENT_KEY; implies MQTT_TLS. --doctor checks the files without connecting |
MQTT_CLIENT_KEY | --mqtt-client-key · BIRDNET_MQTT_CLIENT_KEY | — | PEM file with the private key for MQTT_CLIENT_CERT (PKCS#8, PKCS#1 or SEC1). Never quoted in an error |
MQTT_TLS_SERVER_NAME | --mqtt-tls-server-name · BIRDNET_MQTT_TLS_SERVER_NAME | the broker host | The name to check the broker’s certificate against, for a broker reached by IP |
BIRDWEATHER_THRESHOLD | — (the BirdWeather upload threshold field on the Settings page) | — | A confidence (0 to 1) a detection must reach to be uploaded to BirdWeather, on top of the station’s own threshold; the station’s log keeps everything it recorded. Unset or 0 uploads every detection the station records. A value above 1 (75 typed for 75 %) is refused, not clamped: the bar is then ignored (every detection is uploaded) and --doctor and the start-up log say so. It never affects the blur setting |
BIRDWEATHER_LOCATION_ACCURACY | — (the BirdWeather location blur field) | 0 (exact) | Metres, 0 to 50 000: every post carries the station’s position moved by one fixed offset (a distance up to this, in a direction, drawn once from a secret seed kept in birdweather-blur-seed beside the database), so the public map does not show the station’s address. The offset is the same on every post and after every restart, which is what stops someone averaging many posts back to the true spot; it is not hidden from anyone who learns it (the seed file, or another source that places the station), and changing the radius or losing the seed file draws a new one. A value that is set and cannot be used (5km, 60000, a negative number) turns BirdWeather uploads off rather than posting the exact position; --doctor and the start-up log say so |
EMBED_ORIGINS | — (the Sites that may embed this station field) | — (nobody) | Comma-separated origins (http://homeassistant.local:8123, https://abcd1234.ui.nabu.casa, https://*.example.com) allowed to show the station in an <iframe>. Anything else, * included, and a wildcard over a suffix strangers register under (*.ui.nabu.casa, *.github.io, *.co.uk) is refused and then no site may embed. Only /, /kiosk and /recordings can be framed. See Embedding |
MQTT_USERNAME | --mqtt-username · BIRDNET_MQTT_USERNAME | — | Broker user name |
MQTT_PASSWORD | --mqtt-password · BIRDNET_MQTT_PASSWORD, or BIRDNET_MQTT_PASSWORD_FILE | — | Broker password. Prefer the file form: a flag is visible in ps |
MQTT_TOPIC_PREFIX | --mqtt-topic-prefix · BIRDNET_MQTT_TOPIC_PREFIX | birdnet | Detections go to {prefix}/detection/{species}, the heartbeat to {prefix}/status |
TLS_CERT | --tls-cert · BIRDNET_TLS_CERT | — | Certificate chain (PEM) for TLS_MODE=manual |
TLS_KEY | --tls-key · BIRDNET_TLS_KEY | — | Private key (PEM) for TLS_MODE=manual |
TLS_DIR | --tls-dir · BIRDNET_TLS_DIR | a tls folder beside the database | Where the generated CA and server certificate are kept |
PRIVACY_RANK | --privacy-rank · BIRDNET_PRIVACY_RANK | 10 | BirdNET-Pi’s human-sound rule; 0 = off, up to 12000. On by default. A field on the Settings page (Privacy Rank) |
WATCHDOG_CHECK_SECS | BIRDNET_WATCHDOG_CHECK_SECS | 2 (1–300) | How often every capture source is checked |
WATCHDOG_STALL_SEGMENTS | BIRDNET_WATCHDOG_STALL_SEGMENTS | 4 (2–60) | Segments of missing output before a running source is called stalled |
WATCHDOG_STALL_FLOOR_SECS | BIRDNET_WATCHDOG_STALL_FLOOR_SECS | 120 (10–3600) | Floor under that stall threshold |
WATCHDOG_BACKOFF_BASE_SECS | BIRDNET_WATCHDOG_BACKOFF_BASE_SECS | 2 (1–60) | First retry delay after a source dies |
WATCHDOG_BACKOFF_CAP_SECS | BIRDNET_WATCHDOG_BACKOFF_CAP_SECS | 60 (2–3600) | Ceiling on the retry delay; there is no retry limit |
WATCHDOG_DOWN_WARN_AFTER_SECS | BIRDNET_WATCHDOG_DOWN_WARN_AFTER_SECS | 120 (10–86400) | How long a source may be down before the loud journal warning |
WATCHDOG_DOWN_WARN_EVERY_SECS | BIRDNET_WATCHDOG_DOWN_WARN_EVERY_SECS | 300 (30–86400) | How often that warning repeats |
A watchdog value outside its range is clamped and the adjustment is logged; one that is not a number falls back to the default. The ranges and defaults are in src/capture/watchdog.rs.
--offsite-backup accepts off, s3, sftp and rsync: the CLI help below says “off, s3 or sftp”, which predates the fourth. See Backups & Recovery.
Environment variables — .env.example
# =============================================================================
# BirdNet-Behavior — Docker environment variables
#
# QUICK START
# 1. cp .env.example .env
# 2. Uncomment and fill in the REQUIRED section below (lat/lon + one
# audio source). See "why some keys are commented out" below.
# 3. docker compose up -d ← base compose, works for RTSP/file-watch
# …or an overlay for live mic capture:
# docker compose -f docker-compose.yml -f docker-compose.alsa.yml up -d
# docker compose -f docker-compose.yml -f docker-compose.pulse.yml up -d
# 4. Open http://localhost:8502
#
# The classifier is installed automatically on first run. A new data volume gets
# BirdNET V2.4 (~85 MB with its range filter) — the network BirdNET-Pi and
# birdnet-go run, so detections agree with theirs — installed by the image's own
# `birdnet-behavior --install-model`, which checks every file's sha256 and size
# before it keeps it. The BirdNET+ V3.0 preview (~560 MB) is an explicit opt-in:
# BIRDNET_CLASSIFIER=birdnet-v3 (see "Classifier" below). A volume that already
# runs the V3.0 preview keeps running it. You do not need to fetch a model
# yourself.
#
# Settings with no env var (detection confidence threshold, sensitivity,
# per-species thresholds, email, rare-bird quarantine) live in the web UI at
# http://localhost:8502/admin/settings. Their defaults are BirdNET-Pi's —
# confidence 0.70, sensitivity 1.25 — and are what a BirdNET V2.4 station should
# run; they are not set here, so they follow the build.
#
# WHY SOME KEYS BELOW ARE COMMENTED OUT
# Uncomment a key to use it, and always give it a value. A key left in place
# with an empty value is NOT the same as leaving it unset: the daemon's
# argument parser reads a blank environment variable as a *supplied* value,
# so `BIRDNET_LATITUDE=` means "the latitude is the empty string" and refuses
# to start rather than falling back to the default. Every optional key is
# therefore shipped commented; `docker/entrypoint.sh` strips blanks as a
# backstop, but `docker compose exec … --doctor` reads this file's values
# directly and gets no such protection.
#
# A key that the web UI also sets is commented out for a second reason: an
# environment variable counts as an explicit flag and wins over
# /admin/settings for good. Set one here only to pin the value — the
# settings page then shows your choice and silently runs this one.
# =============================================================================
# =============================================================================
# REQUIRED — edit these three things before first run
# =============================================================================
# --- 1. Station location -----------------------------------------------------
# Used for the solar recording schedule, species frequency filter,
# BirdWeather uploads, and the dashboard sunrise/sunset markers.
#
# Format: decimal degrees (NOT degrees-minutes-seconds). Negative numbers
# mean South latitude or West longitude. Easiest way to find your numbers:
# 1. Open https://www.openstreetmap.org/
# 2. Right-click your house / garden / station location
# 3. Choose "Show address" — the two numbers at the top are lat,lon.
#
# Examples:
# Boston, MA, USA → BIRDNET_LATITUDE=42.3601 BIRDNET_LONGITUDE=-71.0589
# Berlin, Germany → BIRDNET_LATITUDE=52.5200 BIRDNET_LONGITUDE=13.4050
# Sydney, Australia → BIRDNET_LATITUDE=-33.8688 BIRDNET_LONGITUDE=151.2093
#
# Validation: latitude must be between -90 and 90, longitude between -180
# and 180. Run `birdnet-behavior --doctor` after editing to verify.
#BIRDNET_LATITUDE=
#BIRDNET_LONGITUDE=
#
# Timezone, as an IANA name (Europe/Berlin, America/New_York, Australia/
# Sydney). Detections are filed under the container's local hours, and
# those come from this. Unset, the station runs on UTC and every hour on
# the dashboard is a UTC hour; a name the image does not know also means
# UTC, and the entrypoint and `--doctor` say so.
#TZ=
# --- 2. Audio source — set exactly ONE of the following ---------------------
# Option A — USB / ALSA microphone (most Raspberry Pi users)
# Find the device: arecord -l
# → "card 1: Device [USB Audio], device 0:" → plughw:1,0
# Start with:
# docker compose -f docker-compose.yml -f docker-compose.alsa.yml up -d
#BIRDNET_ALSA_DEVICE=
# Several microphones at once: a comma-separated list, each analysed and
# recorded as its own source (the single-device key above is then ignored).
#BIRDNET_ALSA_DEVICES=
# Option B — RTSP stream (IP camera / network audio)
# Works with the base docker-compose.yml — no overlay needed.
# docker compose up -d
#BIRDNET_RTSP_URL=
# Option C — PulseAudio / PipeWire source (desktop Linux)
# Use "default" for the system default source, or a name from:
# pactl list short sources
# Start with:
# docker compose -f docker-compose.yml -f docker-compose.pulse.yml up -d
#BIRDNET_PIPEWIRE_DEVICE=
# --- 3. Which image to pull --------------------------------------------------
# latest — the current release. DuckDB behavioral analytics (activity
# sessions, resident-vs-migrant, dawn-chorus validation, …) is
# built into EVERY image and on by default — there is no separate
# "-analytics" tag. Pin a version in production, e.g. 0.5.0.
BIRDNET_IMAGE_TAG=latest
# =============================================================================
# OPTIONAL — safe defaults, edit only if you want to change behaviour.
# You can leave this entire section untouched and the container will work.
# =============================================================================
# --- Web server --------------------------------------------------------------
# Host port for the web UI (container always listens on 8502 internally).
BIRDNET_PORT=8502
# Custom site name shown in the web UI header.
#BIRDNET_SITENAME=
# Species info link target: ebird | allaboutbirds | none
#BIRDNET_INFO_SITE=ebird
# Web UI authentication (a session-cookie login at /login). Viewing the dashboard is always open; only
# the /admin panel (settings, backups, software update) is gated by a password.
# quickstart.sh and the bare-metal installer generate one. A hand-made .env does
# not, so until you set CADDY_PWD here, /admin is open to the whole LAN
# (BIRDNET_LISTEN below binds 0.0.0.0). Set a password, or front it with a
# reverse proxy / VPN. You sign in as "admin"; CADDY_USER only adds a second
# name for the same account. See docs/book/field/hardening.md.
CADDY_USER=
CADDY_PWD=
# Private mode: put the WHOLE station behind the sign-in, not only /admin. On a
# station reachable through a tunnel or a port forward, the open dashboard is
# the detection history and a live microphone feed for anyone with the URL.
# With this set, everything needs a session except the sign-in form, its
# assets, the health probe and what BIRDNET_PUBLIC_ACCESS names. Needs
# CADDY_PWD: a private station with no password answers 503 rather than
# falling open. Config file: PRIVATE_MODE.
#BIRDNET_PRIVATE_MODE=true
# What stays open on a private station, comma-separated: live_audio (the
# /stream endpoint, its HLS variant under /api/v2/streams/hls/ and the live
# spectrogram), share (operator-minted /r/<token>
# links) and metrics (/api/v2/metrics, for a scraper with no cookie). No effect
# without private mode. Config file: PUBLIC_ACCESS.
#BIRDNET_PUBLIC_ACCESS=share,metrics
# --- HTTPS -------------------------------------------------------------------
# How the dashboard terminates TLS: off | self-signed | manual
#
# off Plain HTTP (the default, and what every earlier release did).
# Fine on a trusted LAN, or behind a reverse proxy that already
# terminates TLS.
# self-signed Generates a small local CA and a server certificate it signs,
# under BIRDNET_TLS_DIR. Browsers warn until you import the CA
# file once (the startup log prints its path); a later
# certificate rotation does not bring the warning back.
# manual Serves BIRDNET_TLS_CERT / BIRDNET_TLS_KEY. Both are re-read
# when they change on disk, so an ACME client renewing them at
# 03:00 is picked up without a restart.
#
# `--doctor` checks all of this before startup does.
#BIRDNET_TLS_MODE=off
# Certificate chain and private key (PEM), for BIRDNET_TLS_MODE=manual.
#BIRDNET_TLS_CERT=/etc/ssl/birdnet/fullchain.pem
#BIRDNET_TLS_KEY=/etc/ssl/birdnet/privkey.pem
# Where HTTPS listens. Defaults to BIRDNET_LISTEN's host on port 8503, so plain
# HTTP keeps answering on 8502. Set this equal to BIRDNET_LISTEN to serve only
# HTTPS on the one port.
#BIRDNET_TLS_LISTEN=0.0.0.0:8503
# Answer plain HTTP with a 308 redirect to HTTPS instead of serving the app.
# Ignored (with a warning) when HTTPS and HTTP share one socket.
#BIRDNET_TLS_REDIRECT=1
# Names the generated certificate covers, comma-separated. Defaults to
# localhost, this machine's hostname, <hostname>.local, the bound address and
# 127.0.0.1. Changing this regenerates the server certificate (not the CA).
#BIRDNET_TLS_HOSTNAMES=birds.example.com,192.168.1.40
# Where the generated CA and server certificate live. Defaults to a `tls`
# directory beside the database.
#BIRDNET_TLS_DIR=/data/tls
# How many days a generated server certificate is valid for. The CA that signs
# it lives ten years regardless, so rotation never invalidates a trust import.
#BIRDNET_TLS_DAYS=397
# Extra seconds of audio before each detection in its saved clip.
#
# A clip is centred on its detection: with the default 6-second EXTRACTION_LENGTH
# and BirdNET's 3-second window that is 1.5 seconds either side. This adds to
# the front only, so 1.0 makes the lead-in 2.5 seconds and the clip 7 seconds.
#
# Worth having because a call is not centred in the window that detects it. A
# bird that started singing half a second before the analysed chunk opened has
# its first notes in the previous one — audible to a person deciding whether the
# identification is right, and exactly what an asymmetric lead-in recovers.
#
# Clips now span segment boundaries, so this reaches real audio even for a
# detection at the very start of a segment. It is refused across a gap in
# recording: a station that restarted, or a source that dropped, gives a shorter
# clip rather than one splicing two different times together.
#
# 0 (the default) keeps clips centred, exactly as before.
# Config-file key only: the daemon reads PRE_CAPTURE_SECS from birdnet.conf, not
# from the environment, so it has no effect in Docker (which ships no config file).
#PRE_CAPTURE_SECS=1.0
# Species tracking: first of the year, first of the season, returning.
#
# The season a date falls in depends on the station's latitude — March is
# spring in the north and autumn in the south, and near the equator there are
# wet and dry seasons rather than four temperate ones. Set BIRDNET_LATITUDE (or
# the latitude in Settings) and the station works this out; leave it unset and
# it reports no season at all rather than guessing a northern one.
#
# The tracking year defaults to the calendar year. A station that counts its
# year from the start of the breeding season can move the reset, and a date
# before it then belongs to the tracking year that began the previous time
# around. These are settings-table keys rather than environment variables
# because they belong with the station's other operator settings:
#
# tracking_year_reset_month 1-12, default 1
# tracking_year_reset_day 1-31, default 1
#
# "Returning after an absence" shares its threshold with the rare-species feeds
# (rare_species_days, default 30) so the RSS feed and the day list cannot
# disagree about which birds are notable.
#
# Read it at GET /api/v2/species/tracking?date=YYYY-MM-DD[¬able_only=true].
# Dynamic per-species confidence threshold. Off by default.
#
# A fixed confidence threshold settles two different questions with one number:
# "is this a bird at all" and "is this bird plausible here". Once a Tawny Owl
# has been recorded in the wood at 0.9, a later 0.45 Tawny Owl is very probably
# another Tawny Owl; a 0.45 for a species never recorded within 500 km is not.
# Raising the threshold to suppress the second suppresses the first as well.
#
# When enabled, a species is confirmed by a detection at or above the trigger
# that survived EVERY other gate — the privacy and noise filters, the species
# occurrence (geomodel) filter, the plausible-hour filter, the corroboration
# filter, and the threshold itself. Each confirmation lowers that species'
# threshold one step (x0.75, x0.50, x0.25), floored, and the level lapses after
# the configured hours without reinforcement. A single singing bout counts once:
# a species can advance at most one level per fifteen minutes.
#
# Enabling this also lowers the threshold the classifier itself runs at, to the
# floor below — otherwise the detections it exists to recover are discarded
# inside the model and the feature reaches nothing. The extra cost is in
# post-processing, not inference.
#BIRDNET_DYNAMIC_THRESHOLD=1
# Confidence at or above which a surviving detection confirms its species.
# Should sit well above the global threshold: the point is that the station was
# sure, not merely that it accepted the detection. Default 0.80.
#BIRDNET_DYNAMIC_THRESHOLD_TRIGGER=0.80
# Hard floor. No species goes below this whatever its level. This is the number
# that bounds the damage if the reasoning above is wrong for your site.
# Must be BELOW the global confidence or nothing can be adjusted; the station
# warns at startup if it is not. Default 0.25.
#BIRDNET_DYNAMIC_THRESHOLD_MIN=0.25
# How long a level survives without reinforcement, in hours. Default 24.
#BIRDNET_DYNAMIC_THRESHOLD_HOURS=24
# Sound-level calibration, in dB SPL at digital full scale.
#
# Unset means the station reports dBFS: negative, relative to its own gain, and
# not comparable with another station or a published figure. That is fine for
# every question about change at one place over time — is it getting louder, is
# the microphone dying, when does the dawn chorus start — which is what the
# third-octave series is for.
#
# To calibrate, play a known level at the microphone (a 94 dB SPL acoustic
# calibrator at 1 kHz is the usual instrument), read what /api/v2/soundlevel
# reports for the 1 kHz band uncalibrated, and set this to
# 94 minus that reading. Readings then come back as dB SPL and say so; the unit
# travels with the data, and each stored hour records the offset in force when
# it was measured, so a series that spans a calibration has a visible seam
# rather than a silent one.
#BIRDNET_SPL_CALIBRATION_DB=
# Reverse proxies whose forwarded client-IP headers may be believed.
#
# Comma-separated CIDR blocks, bare addresses, and the reserved names
# `loopback`, `private` and `cloudflare`. Loopback is always trusted whether or
# not it is named — a proxy on the same host is not a stranger.
#
# The default is `private`: loopback plus RFC1918, unique-local and link-local.
# That covers the two common topologies (a proxy on this host, a proxy on
# another box on the LAN) and is why a proxied station records each visitor
# separately rather than filing the whole household under the proxy's address.
#
# When the peer is NOT in this list, forwarded headers are ignored completely
# and the connection's own address is used. That is what stops a directly
# exposed station from being told who its callers are by the callers.
#
# Set `loopback` to trust nothing but this machine. Add `cloudflare` when the
# station sits behind Cloudflare, so the right-to-left walk of
# `X-Forwarded-For` passes the edge and reaches the real visitor instead of
# stopping at one of a few hundred Cloudflare addresses. Naming it is also
# what makes `CF-Connecting-IP` believed: only Cloudflare overwrites that
# header, and Caddy or nginx pass a visitor's own copy straight through, so
# without the name it is ignored. (A Cloudflare Tunnel works either way — the
# edge appends the visitor to `X-Forwarded-For`.)
#
# A value that does not parse is not ignored: it is logged and the station
# falls back to loopback only.
#BIRDNET_TRUSTED_PROXIES=private
#BIRDNET_TRUSTED_PROXIES=private,cloudflare
#BIRDNET_TRUSTED_PROXIES=loopback
#BIRDNET_TRUSTED_PROXIES=10.0.0.0/8,192.168.1.5
# Host names that may use the admin panel while no admin password is set.
#
# A station with no password lets anyone on its network into /admin. It only
# does that for names nobody outside your network can point at it: an IP
# address, `localhost`, a one-word name like `birdnet`, and names ending in
# .local, .lan, .home, .home.arpa, .internal or .localdomain. Any other name —
# one somebody registered — is refused, because DNS rebinding would otherwise
# let any website you visit operate the station through your browser.
#
# Setting an admin password is the better fix and makes this setting moot. If
# you reach an open station by your own domain and want that to keep working,
# list it here (comma-separated, no port):
#BIRDNET_ALLOWED_HOSTS=birds.example.org
# Serve the station from under a path, e.g. https://home.example/birdnet
#
# For the common home setup: one hostname, a reverse proxy, several services
# under different paths. Without this the station needs its own subdomain or
# its own port. Home Assistant ingress works this way too.
#
# Empty (the default) serves from the root and changes nothing.
#
# The prefix is applied to every URL the station emits — links, form actions,
# HTMX targets, redirects, the session cookie's Path, and the WebSocket URLs
# the live pages build in the browser. Your proxy should pass the full path
# through *unchanged* rather than stripping it: the station expects to see
# /birdnet/today, not /today.
#
# Caddy: handle_path is wrong here; use `handle /birdnet/*` and
# `reverse_proxy localhost:8080` with no path rewriting.
# nginx: location /birdnet/ { proxy_pass http://127.0.0.1:8080; }
# (note: no trailing path on proxy_pass, or nginx strips the prefix)
#
# A visitor who reaches the host root is redirected to the prefix, so a
# mistyped bookmark lands on the station rather than on a 404.
#
# A value that does not parse (a `..` segment, whitespace, a query string) is
# refused and logged, and the station serves from the root — it is not
# silently corrected, because a corrected prefix will not match what the proxy
# sends and every request then 404s with nothing in the log to say why.
#BIRDNET_BASE_PATH=/birdnet
# Cross-origin (CORS) allow-list. Empty = same-origin only (the secure default).
# Set a comma-separated list of origins ONLY if you call the API from another
# host, e.g. https://dashboard.example.com
#BIRDNET_CORS_ALLOWED_ORIGINS=
# Secret used to sign cookie sessions on the new branded login page (O-14).
# Set a random value of 32+ bytes so sessions survive restarts and process
# moves. If unset, the secret is derived deterministically from CADDY_PWD
# (rotating the password signs out every existing session — the intended
# semantics). If both are unset, a per-process random secret is used and
# every outstanding session invalidates on restart. Generate one with:
# openssl rand -base64 48
BNB_SESSION_SECRET=
# Cookie session lifetime in days, default 14, range 1-365. "Remember me"
# extends the issued cookie to 90 days regardless of this value.
BNB_SESSION_TTL_DAYS=14
# Where the rendered manual lives, for the in-app help drawer. Unset, the
# binary looks beside itself and under docs/book/_generated/html.
#BNB_HELP_DIR=
# Token for the token-gated /api/v2 endpoints (O-1): eighteen of them, ten
# writes and eight reads (the lists are WRITE_ROUTES and READ_ROUTES in
# crates/birdnet-web/src/routes/api_write.rs; the manual's API page names them).
# Leave this blank and the station has NO write API at all: every one of the
# eighteen answers 404, which is the default and the safe one. Set it and they
# accept `Authorization: Bearer <this value>` — which is how Home Assistant,
# Node-RED or a shell script confirms a detection, locks a clip against the
# purge, deletes a misidentification, changes a setting (PUT /api/v2/settings)
# or restarts the station (POST /api/v2/control/restart).
#
# This is NOT the /admin password and does not inherit its behaviour: /admin is
# open when CADDY_PWD is unset, the write API is closed when this is unset.
# Anything shorter than 32 bytes is refused and leaves the write API off, with a
# warning in the log. Generate one with:
# openssl rand -base64 48
BNB_API_TOKEN=
# Absolute base URL of this station (e.g. https://birds.example.com). When
# the URL starts with https://, the session cookie is issued with Secure;
# on a plain-HTTP LAN this stays off so browsers send the cookie back.
# Independent of BNB_BASE_URL below: the cookie reads only this one. The links
# in push notifications ($listenurl, $friendlyurl, $image) read this first and
# then BNB_BASE_URL, and fall back to http://<hostname>.local:<port>.
BNB_PUBLIC_URL=
# --- Sharing, feeds & station coordinates (web features) ---------------------
# Secret used to sign public share links (/r/<token>) with HMAC-SHA256. Set a
# random value of 32+ bytes. If left blank the key is derived from the station's
# own session secret (random, persisted beside the database), so links survive
# restarts either way; set this only to rotate share links independently.
# Generate one with: openssl rand -base64 48
BNB_SHARE_SECRET=
# Absolute base URL of this station as seen by the outside world. Used to build
# absolute links in the RSS/iCal feeds and share pages (e.g. http://birdnet.local
# or https://birds.example.com). Defaults to http://localhost:8502 if unset.
BNB_BASE_URL=
# Station latitude/longitude used for the dawn-chorus sunrise/sunset overlay on
# /analytics/dawn-chorus. Falls back to BIRDNET_LATITUDE/LONGITUDE, then to a
# conservative 05:30/20:00 if neither is set. Same values feed the weather
# poll loop below — locale-tolerant (42,3601 works the same as 42.3601).
BNB_STATION_LAT=
BNB_STATION_LON=
# --- Weather overlay (O-23) --------------------------------------------------
# Off by default — a fresh install does NOT contact api.open-meteo.com until
# you opt in. Set BNB_WEATHER_ENABLED=1 to start a background task that pulls
# the hourly forecast every 30 minutes and paints it as a signal-context band
# on the day-strip and dawn-chorus charts. The moon-phase badge is always
# shown regardless (it's a local calculation).
#
# Privacy-sensitive operators can self-host Open-Meteo and point the client
# at it via BNB_WEATHER_BASE_URL (e.g. http://open-meteo.lan); the upstream
# default is the free api.open-meteo.com tier.
BNB_WEATHER_ENABLED=
BNB_WEATHER_BASE_URL=
#
# WHERE THE WEATHER COMES FROM (G-26). Four choices, set in birdnet.conf as
# WEATHER_PROVIDER (there is no env var; it is a config-file key):
#
# open-meteo the default, and what you get if the key is absent. Free, no
# key, self-hostable.
# met-no the Norwegian Meteorological Institute's Locationforecast.
# Also keyless, and a better model over Europe.
# wunderground YOUR OWN personal weather station. Needs WEATHER_STATION_ID
# and WEATHER_API_KEY. This is the interesting one: an
# anemometer ten metres from the microphone is ground truth,
# and a far better covariate for bird activity than any
# gridded forecast.
# pirate-weather Pirate Weather's hourly forecast (api.pirateweather.net).
# Needs WEATHER_API_KEY; a free key is available from them.
#
# A provider name nobody implements does not start the poll at all, rather than
# quietly serving you a forecast you did not ask for.
#
# The API key can be mounted from a file instead of set here, like the other
# credentials -- see the mounted-secrets block further down.
# --- eBird corroboration (G-27) ------------------------------------------------
# "Has anybody else seen this bird around here lately?" eBird's recent
# observations are the best available answer, and a much better one than the
# range model, which is a static climatological prior with no idea that the
# first migrant of the year arrived last Tuesday.
#
# Used in two places: the suspect-species report under Settings > Data marks a
# flagged species that somebody reported nearby -- a strong reason NOT to
# exclude it -- and a detection's detail page says so too.
#
# OFF unless EBIRD_API_KEY is set. There is no enable flag: eBird needs a key
# for every endpoint, so the key is the opt-in, and a station without one
# never contacts eBird at all. Get a free key at
# https://ebird.org/api/keygen
#
# These are birdnet.conf keys, not environment variables. EBIRD_API_KEY can
# also be mounted from a file -- see the mounted-secrets block further down.
#
# EBIRD_API_KEY your eBird API token. Absent = the whole feature is off.
# EBIRD_REGION optional. An eBird region code (US-MA, GB-ENG-CAM). Leave
# it empty and the station's own LATITUDE/LONGITUDE are used
# instead, which is the better default: "reported within
# 25 km" says far more than "reported somewhere in this
# state". Set it if your area genuinely has no eBird
# observers within 50 km -- a region will find some.
# EBIRD_DIST_KM search radius when using coordinates. 0-50, default 25.
# EBIRD_BACK_DAYS how many days back to ask about. 1-30, default 14.
#
# Your coordinates are sent rounded to two decimal places (about a
# kilometre), which is all eBird accepts and all it needs.
#
# A species eBird says nothing about is shown no differently from before.
# That silence is not evidence: eBird coverage follows birdwatchers, not
# birds, so a quiet valley has no checklists for any species.
#
# BNB_EBIRD_BASE_URL overrides eBird's host, for a caching proxy in front of
# it. Leave it empty for api.ebird.org.
BNB_EBIRD_BASE_URL=
# --- Multi-stream RTSP (overrides BIRDNET_RTSP_URL) --------------------------
# Comma-separated; each URL gets its own capture pipeline.
#BIRDNET_RTSP_URLS=
# --- Network / privacy ---------------------------------------------------------
# Two connections are made with no configuration: a daily release check against
# api.github.com, and Wikipedia species-image downloads. Everything else is off
# until you configure it. See docs/book/getting-started/configuration.md.
#
# Any non-empty value turns off BOTH of the above, so the station makes no
# outbound connection you did not ask for. Integrations you configured
# explicitly (Apprise, BirdWeather, MQTT, SMTP, heartbeat, weather) keep working.
#BIRDNET_OFFLINE=
# Non-empty = skip only the daily release check.
#BIRDNET_NO_UPDATE_CHECK=
# --- Recording schedule ------------------------------------------------------
# Seconds per captured segment, range 3–600 (default 15 is a good balance).
#BIRDNET_SEGMENT_DURATION=15
# Schedule mode:
# all-day — capture audio 24/7 (default)
# solar — sunrise to sunset only (needs lat/lon)
# fixed:HH:MM-HH:MM — local-time window (e.g. fixed:06:00-20:00)
#BIRDNET_RECORDING_SCHEDULE=all-day
# Non-empty value = suppress recording during night hours (needs lat/lon).
#BIRDNET_NIGHT_INHIBIT=
# Minutes offset from sunrise/sunset for twilight recording. Applies to both
# ends of the day unless one of the two below overrides it for its own end.
BIRDNET_TWILIGHT_OFFSET=30
# Set either of these to give dawn and dusk different windows — the dawn chorus
# usually wants a longer lead-in than the evening needs a tail. Unset falls back
# to BIRDNET_TWILIGHT_OFFSET. Both are also editable at /admin/settings.
#BIRDNET_PRE_SUNRISE_OFFSET=
#BIRDNET_POST_SUNSET_OFFSET=
# --- Classifier --------------------------------------------------------------
# Which network the container runs:
#
# birdnet-v24 BirdNET V2.4 + its range filter v2. The default for a NEW data
# volume. The network BirdNET-Pi and birdnet-go run (48 kHz, 3 s
# windows); with the settings below at their defaults, its
# detections agree with BirdNET-Pi's. Labels carry BirdNET-Pi's
# English names. Installed by `birdnet-behavior --install-model`.
# birdnet-v3 BirdNET+ V3.0 preview + the BirdNET geomodel. A different
# network (4.5 s windows at 32 kHz, calibrated probabilities,
# ~11 500 species): its detections differ from BirdNET-Pi's by
# design. ~560 MB.
#
# Left unset, a volume that already holds the V3.0 model keeps running it: an
# image upgrade must never swap the network under a running station's history
# (the container log says so once, at start). Set birdnet-v24 to move such a
# volume on purpose — earlier detections stay in the database and were scored by
# the other network. The two occurrence filters are never mixed: V2.4 gets the
# range filter, V3.0 the geomodel. Read once by the entrypoint; the daemon never
# sees it.
#BIRDNET_CLASSIFIER=birdnet-v24
# --- Detection / inference ---------------------------------------------------
# Note: confidence threshold and sensitivity are configured in /admin/settings
# (defaults 0.70 and 1.25, BirdNET-Pi's).
# Analysis window overlap, 0.0–2.9 seconds (higher = more sensitive, more CPU).
BIRDNET_OVERLAP=0.0
# Seconds of audio saved around each detection (BirdNET-Pi: EXTRACTION_LENGTH).
# Also editable at /admin/settings. 1-60; longer clips verify better by ear and
# cost proportionally more disk.
#BIRDNET_EXTRACTION_LENGTH=6
# --- Logging ---------------------------------------------------------------
# Global level: trace, debug, info, warn, error, off. RUST_LOG overrides this.
#BIRDNET_LOG_LEVEL=info
# Per-subsystem levels, e.g. `audio=debug,web=warn`. Names are operator-facing:
# audio, detection, web, db, integrations, analytics. An explicit Rust path
# (birdnet_core::inference=trace) also works. Unknown names are reported at
# startup rather than ignored.
#BIRDNET_LOG_MODULES=
# --- Species occurrence filtering -------------------------------------------
# The occurrence ("geo") filter drops species that do not occur near the station
# at this time of year. Each classifier has its own, and they are never mixed:
#
# BirdNET V2.4 (the default) -> the range filter v2, installed on first start
# by `birdnet-behavior --install-model birdnet-v24-range-v2`. It is indexed
# like the V2.4 labels, so it needs coordinates and the one file
# (BIRDNET_METADATA_MODEL) and NO label file: leave BIRDNET_METADATA_LABELS
# unset with it, and do not point V2.4 at the geomodel or V3.0 at this.
# BirdNET+ V3.0 (the preview) -> the geomodel, below.
#
# The geomodel needs THREE things and is inert without all three:
#
# 1. station coordinates (BNB_STATION_LAT / BNB_STATION_LON, or the dashboard)
# 2. the geomodel file -> BIRDNET_METADATA_MODEL
# 3. that model's own label file -> BIRDNET_METADATA_LABELS
#
# For V3.0 the entrypoint downloads (2) and (3) on first start and sets both
# variables for you; for V2.4 it installs the range filter and sets
# BIRDNET_METADATA_MODEL. Leave them blank unless you are staging your own. Set
# BIRDNET_METADATA_MODEL yourself and the entrypoint leaves the geomodel
# entirely alone; set only BIRDNET_METADATA_LABELS and it keeps your label file
# beside the geomodel it downloads. If the ~14 MB download fails the container still starts and
# detects; it just does not filter by location, and says so.
# `birdnet-behavior --doctor` reports which of the three is missing.
#
# The geomodel and the classifier do NOT score the same species list (12 012
# against the V3.0 Global 11K classifier's 11 560), so the label file is what
# maps one to the other and the two are matched by scientific name. Leave it
# blank only for a metadata model indexed identically to the classifier -- a
# matched BirdNET pair -- which the station verifies at startup and refuses if
# it does not hold. The label file also carries each species' eBird code, which
# the species page's "View on eBird" link is built from; without it that link
# is not shown.
#BIRDNET_METADATA_MODEL=
#BIRDNET_METADATA_LABELS=
# Point the geomodel fetch at a mirror (air-gapped / bandwidth-limited sites):
#BIRDNET_GEOMODEL_BASE=
# ---------------------------------------------------------------------------
# Running more than one classifier (G-10 Stage 2)
# ---------------------------------------------------------------------------
#
# A second classifier is a second opinion. Two independent models agreeing on
# a species is far stronger evidence than one model being confident, and it is
# the honest way at the false-positive problem. It is also several hundred
# megabytes of RAM.
#
# The station refuses to load a classifier it cannot show will fit: all
# classifiers together may use at most HALF the smaller of physical RAM and
# any cgroup limit, counting each model's file size plus a 256 MiB working-set
# allowance. On a 1 GB board that budget is 512 MiB, which one BirdNET model
# already fills — so a second is skipped, with the arithmetic in the journal.
# A machine that does not report its memory gets one classifier, because
# "unknown" must not be read as "plenty".
#
# The primary classifier (BIRDNET_MODEL / BIRDNET_LABELS) is always loaded. It
# is what the station is.
#
# Name the primary, if you want it called something other than `birdnet` in
# routes and on detections:
#BIRDNET_MODEL_ID=birdnet
#BIRDNET_MODEL_THRESHOLD=
#BIRDNET_MODEL_SAMPLE_RATE=
# A second and third classifier. Each needs its OWN labels file: labels are
# positional against a model's output, so the wrong ones report species under
# other species' names with full confidence.
#BIRDNET_MODEL_2_PATH=/data/model/perch.onnx
#BIRDNET_MODEL_2_LABELS=/data/model/perch_labels.csv
#BIRDNET_MODEL_2_ID=perch
#BIRDNET_MODEL_2_THRESHOLD=
# The sample rate the classifier wants, when its tensor cannot say. Perch v2
# declares [-1, 160000], which is 5 s at 32 kHz and equally 3.33 s at 48 kHz —
# nothing in the shape distinguishes them, so it must be told.
#BIRDNET_MODEL_2_SAMPLE_RATE=32000
#BIRDNET_MODEL_3_PATH=
#BIRDNET_MODEL_3_LABELS=
#BIRDNET_MODEL_3_ID=
#BIRDNET_MODEL_3_THRESHOLD=
#BIRDNET_MODEL_3_SAMPLE_RATE=
# Which classifiers judge which audio source: `source:model`, or
# `source:model+model` for more than one, comma-separated. The source is its
# `audio_sources` row id, the same label GET /api/v2/system/capture lists.
#
# A source not named here uses the PRIMARY classifier — never nothing. A route
# naming a classifier that is not configured stops the station at startup
# rather than leaving that microphone silently unjudged for months.
#BIRDNET_MODEL_ROUTES=pond:perch,garden:birdnet+perch
#
# The two label files were frozen at different points in a moving taxonomy, so
# they do not always spell a species the same way: the geomodel writes
# `Leuconotopicus villosus` where the classifier writes `Dryobates villosus`,
# and both mean Hairy Woodpecker. A name that does not match exactly is tried a
# second way -- on the common name together with the specific epithet -- which
# on the pinned pair recovers 62 species, 41 of them birds, that were otherwise
# undetectable while the occurrence filter ran. `--doctor` reports the counts.
#
# For a pair that shares neither the genus nor the common name, point this at a
# tab-separated `legacy<TAB>canonical` file of your own. Blank lines and `#`
# comments are skipped; a malformed line is skipped rather than fatal, so a typo
# here cannot take the occurrence filter off a running station. No table ships:
# the obvious one (OpenFauna's aliases.json) is CC BY-SA 4.0 against this
# project's CC BY-NC-SA 4.0, and recovers none of the above in any case.
#BIRDNET_SPECIES_ALIASES=
# Occurrence threshold, range 0.0–1.0. No effect while the filter is off.
# Higher = stricter: only species highly likely at your station/season.
# Lower = looser: more rare/migrant species allowed through.
# Default 0.03 is BirdNET-Pi's recommended starting point.
BIRDNET_SF_THRESH=0.03
# Human-voice privacy filter, range 0.0–1.0.
# 0.0 = disabled (no privacy filtering — fastest).
# 0.02 = recommended starting point if voices are being captured.
# 0.10 = aggressive; may suppress some quiet bird calls too.
BIRDNET_PRIVACY_THRESHOLD=0.0
# BirdNET-Pi's human-sound rule (its filter_humans): drop a window, and the one
# either side, when a human class ranks within the model's top N classes. 10 is
# BirdNET-Pi's default and this station's; 0 turns the rank rule off. It is
# independent of BIRDNET_PRIVACY_THRESHOLD above, and either one suppresses.
#BIRDNET_PRIVACY_RANK=10
# Discard an audio chunk a dog barked in (0.0 = disabled, typical 0.5-0.8). A
# bark is broadband, and the classifier also scores whatever species it most
# resembles — so the bark becomes a bird that was never there.
BIRDNET_NOISE_THRESHOLD=0.0
# Non-bird label names to watch, comma-separated. Unset means Dog; an empty
# value means watch nothing. Others in the label set: Siren, Engine,
# Power tools, Fireworks, Gun, Environmental, Noise — the last two score highly
# on ordinary quiet recordings, so watching them suppresses a great deal.
#BIRDNET_NOISE_CLASSES=
#
# NOISE_REMEMBER_SECS (a birdnet.conf key, no env var): keep suppressing the
# species a bark produced for this many seconds after it. 0 = off, the default.
#
# Why: a dog that barks for a minute is a bark, a gap, a bark, a gap. In the
# gaps there is no Dog above threshold, so the filter above has nothing to fire
# on -- and the classifier, still hearing the tail and the room, produces the
# same phantom species. The filter silences the barks and lets the gaps
# through, which is backwards.
#
# It suppresses ONLY the species that were in the barking chunk, not every
# species. A blanket window would be a mute button: a dog barking through the
# dawn chorus would erase the chorus. A blackbird singing in the same gap is
# still recorded.
#
# Off by default because it does remove real detections whenever a real bird
# happens to be the species a dog resembles. Try 30-60 if a regular dog is
# filling your log with one phantom species.
#
# It reaches only to the end of the recording being analysed, not across
# segments: carrying it further would need state on a filter shared by every
# audio source, and a bark on one microphone must not silence a species on
# another.
# ---------------------------------------------------------------------------
# Offsite backups
# ---------------------------------------------------------------------------
# The weekly snapshot lives beside the database, on the same card. That covers
# a corrupt page and a bad import; it does not cover the card failing, the
# enclosure flooding, or the Pi being stolen. Set this and each snapshot is
# encrypted on the station and uploaded. Off by default.
#
# off | s3 (AWS, B2, R2, Wasabi, MinIO, Ceph, Garage) | sftp (any SSH host)
BIRDNET_OFFSITE_BACKUP=off
# The passphrase every backup is encrypted under. There is NO way to turn
# encryption off, and NO recovery without this: it is not stored anywhere, not
# derivable from the backup, and not known to anyone else. Write it down
# somewhere that is not this station. At least 12 characters.
#
# Deliberately no command-line flag: an argument is visible in `ps` to every
# user on the machine and is copied into the journal by systemd.
#BIRDNET_OFFSITE_PASSPHRASE=
# How many backups to keep at the destination. 0 keeps everything. Only files
# this station wrote are ever removed.
#BIRDNET_OFFSITE_KEEP=8
# --- S3-compatible ---------------------------------------------------------
#BIRDNET_OFFSITE_S3_ENDPOINT=https://s3.eu-west-2.amazonaws.com
#BIRDNET_OFFSITE_S3_BUCKET=my-birdnet-backups
#BIRDNET_OFFSITE_S3_PREFIX=stations/garden
#BIRDNET_OFFSITE_S3_REGION=eu-west-2
#BIRDNET_OFFSITE_S3_ACCESS_KEY=
#BIRDNET_OFFSITE_S3_SECRET_KEY=
# auto picks by endpoint: virtual-host for AWS, path for everything else.
#BIRDNET_OFFSITE_S3_ADDRESSING=auto
# --- SSH file server -------------------------------------------------------
# Key authentication only; passwords are disabled because a batch upload
# cannot answer a prompt and would hang until something killed it.
#BIRDNET_OFFSITE_SFTP_HOST=backup.example.net
#BIRDNET_OFFSITE_SFTP_PORT=22
#BIRDNET_OFFSITE_SFTP_USER=birdnet
#BIRDNET_OFFSITE_SFTP_DIR=/srv/backups/garden
#BIRDNET_OFFSITE_SFTP_IDENTITY=/var/lib/birdnet/ssh/id_ed25519
# Defaults beside the identity file.
#BIRDNET_OFFSITE_SFTP_KNOWN_HOSTS=/var/lib/birdnet/ssh/known_hosts
# `yes` or `accept-new`. There is no `no`: host key checking is what makes the
# upload go to your server rather than to whoever answers.
#BIRDNET_OFFSITE_SFTP_HOST_KEY_POLICY=yes
# --- How often a backup is taken --------------------------------------------
# weekly (the default) or daily. A birdnet.conf key, not an env var.
#
# Daily means losing at most a day rather than a week -- and seven times the
# offsite upload, which matters on a metered link. Weekly stays the default so
# an existing station's bandwidth does not change underneath it.
#
# This schedules the BACKUP only. The space reclaim (WAL checkpoint, free pages
# returned) is a separate weekly job and stays weekly whatever you set here:
# asking for a daily backup is not asking to rewrite the database file every
# day, which on an SD card is write endurance spent for nothing.
#
# Both jobs are visible at GET /api/v2/system/jobs.
# --- Over rsync, when the link is bad ---------------------------------------
# OFFSITE_BACKUP=rsync sends to the SAME server with the SAME settings above --
# only the program moving the bytes changes, so switching is one word.
#
# Use it when your uplink drops: rsync keeps what already arrived, so a
# transfer that dies at 90% of a 1.3 GB backup continues from there. Over SFTP
# the same backup starts again from zero.
#
# It is NOT faster for being "incremental". Backups are encrypted under a fresh
# random salt each time, so consecutive backups share no blocks at all -- not
# even when the database has not changed. Measured on 1 MiB: 1496/1497 blocks
# reusable in plaintext, 0/1498 once encrypted. The resume and the speed limit
# below are the reasons to use it.
#
# Needs both rsync and sftp installed: rsync moves the bytes, sftp makes the
# directory, lists it and deletes what retention drops.
#
# KiB/s. 0 or unset means no limit.
#BIRDNET_OFFSITE_RSYNC_BWLIMIT=512
# Require a species to be heard in more than one nearby analysis window before
# it is recorded: off, lenient, moderate, balanced, strict. A real bird sings
# across several windows; a classifier artefact usually fires once. Each level
# is a fraction of the windows within six seconds — 20%, 30%, 50%, 70% —
# rounded up.
#
# This does NOTHING without BIRDNET_OVERLAP: with no overlap a six-second
# neighbourhood is two 3-second windows, and 20% of two rounds to one, which
# every detection already meets. `--doctor` reports the overlap each level
# needs. Off by default because it removes rows.
BIRDNET_CONFIRMATION_LEVEL=off
# Suppress a repeat of the same species within this many seconds (0 = off).
# A 15-second recording is five 3-second chunks, so a bird singing throughout
# is recorded five times, and every count in the app is a row count. Off by
# default because switching it on changes how many rows a station records.
BIRDNET_DUPLICATE_INTERVAL_SECS=0
# Quarantine day birds heard in the middle of the night. Owls, nightjars,
# rails, bitterns and thick-knees are exempt by genus. Needs station
# coordinates. Detections are quarantined for review, never dropped, because
# the taxonomy is genus-level and cannot be complete.
BIRDNET_NIGHT_FILTER=0
# Minutes after sunset / before sunrise before the night window opens. Birds
# sing through dusk and start again well before sunrise.
BIRDNET_NIGHT_MARGIN_MINS=60
# Extra genera or scientific names always allowed at night, comma-separated.
# A station recording nocturnal flight calls (migrating thrushes and warblers
# calling overhead) should leave the filter off or name those genera here.
#BIRDNET_NIGHT_EXTRA_NOCTURNAL=
# Language common names are shown in (a display setting: the names stored with
# detections stay the classifier's). 35 languages besides English are built in:
# af ar ca cs da de es et fi fr hr hu id is it ja ko lt lv nl no pl pt ro ru sk sl
# sr sv th tr uk vi zh_CN zh_TW. A regional spelling (de_AT, pt-BR) works; plain
# `zh` does not. Also settable on /admin/settings as DATABASE_LANG.
BIRDNET_LANG=en
# --- Notifications -----------------------------------------------------------
# Delivered in-process, no Apprise installation involved. Apprise URL syntax,
# comma- or newline-separated. Natively supported schemes:
# discord:// slack:// tgram:// ntfy:// gotify:// pover:// json://
# (and ntfys:// gotifys:// jsons://)
#BIRDNET_NOTIFY_URLS=
# For any other scheme, Apprise is still used: either its API server...
#BIRDNET_APPRISE_URL=
# ...or the `apprise` CLI with a config file. When every URL in that file is
# one of the schemes above, the CLI is not invoked at all.
#BIRDNET_APPRISE_CONFIG=
# Ceiling on notifications per destination per minute; 0 disables it. The cap
# is about the services' limits, not ours: Discord allows 5 requests/second per
# webhook, Telegram ~30/second, and Pushover 10,000 messages a *month*.
BIRDNET_NOTIFY_RATE_PER_MINUTE=12
#BIRDNET_NOTIFY_CONFIDENCE=0.8
# Trigger mode: each | new-species | new-species-daily
#BIRDNET_NOTIFY_TRIGGER=each
#BIRDNET_NOTIFY_SPECIES_EXCLUDE=
#BIRDNET_NOTIFY_SPECIES_ONLY=
#BIRDNET_NOTIFY_TITLE_TEMPLATE=
#BIRDNET_NOTIFY_BODY_TEMPLATE=
# Weekly report day: monday | tuesday | … | sunday | disabled
#BIRDNET_WEEKLY_REPORT_SCHEDULE=monday
# --- Detection deadman (end-to-end "is it actually detecting?" watchdog) ------
# Hours of zero detections before a warning is logged and (if Apprise is
# configured) one alert is sent, with a recovery notice when birds return.
# A station that has never detected anything never alarms. 0 disables the
# alert but still exports the birdnet_detection_silence_seconds gauge.
# Raise it for genuinely sparse habitats. Default: 24.
BIRDNET_DEADMAN_HOURS=24
# --- Station health (the faults the deadman cannot see) ----------------------
# The deadman answers "is the station detecting at all?". This answers the
# faults a station keeps detecting straight through, so the deadman stays quiet:
# * an audio source down while others keep recording
# * the disk full enough that recordings are being purged to make room
# * the CPU at or above its throttling point (80 C on a Pi)
# * a backup or integrity check that has not completed in three weeks
# One alert per episode with a recovery notice when it clears, through the same
# Apprise notifier as the deadman, and only after three consecutive five-minute
# polls so a mic that re-enumerates or a momentary disk spike stays quiet.
# Default: true.
BIRDNET_STATION_HEALTH_ALERTS=true
# --- BirdWeather -------------------------------------------------------------
#BIRDNET_BIRDWEATHER_TOKEN=
# Self-hosted ingest endpoint override. Leave blank for the public
# app.birdweather.com map. Set to your own endpoint implementing the
# BirdWeather station API (only the host changes; the /stations/<token>/...
# path shape is preserved) to keep observation locations under your own
# governance — e.g. for rare or endangered species where a public map is a
# poaching risk. The offline queue and ordered replay come with it.
#BIRDNET_BIRDWEATHER_URL=
# --- MQTT (Home Assistant, Node-RED, Mosquitto, …) ---------------------------
#BIRDNET_MQTT_HOST=
# Leave unset for 1883, or 8883 when BIRDNET_MQTT_TLS is on.
#BIRDNET_MQTT_PORT=
BIRDNET_MQTT_CLIENT_ID=birdnet-behavior
# Connect over TLS. The certificate is always verified — there is no way to
# turn that off. Setting BIRDNET_MQTT_CA_FILE turns TLS on by itself.
#BIRDNET_MQTT_TLS=
# PEM file of extra trust anchors. For a broker behind a private CA this is the
# *CA's* certificate; for a self-signed broker it is the broker's own. Either
# way the broker's certificate must carry CA:FALSE — plain `openssl req -x509`
# sets CA:TRUE, which rustls refuses from a server.
#BIRDNET_MQTT_CA_FILE=
# For a broker that requires the station to present a certificate (mutual TLS):
# PEM files of the station's client certificate and its private key. Both or
# neither; either turns TLS on by itself, and `--doctor` checks the pair.
#BIRDNET_MQTT_CLIENT_CERT=
#BIRDNET_MQTT_CLIENT_KEY=
# Hostname to check the certificate against, when connecting by IP.
#BIRDNET_MQTT_TLS_SERVER_NAME=
#BIRDNET_MQTT_USERNAME=
#BIRDNET_MQTT_PASSWORD=
BIRDNET_MQTT_TOPIC_PREFIX=birdnet
#BIRDNET_MQTT_RETAIN=
#BIRDNET_MQTT_HA_DISCOVERY=
# --- Heartbeat (uptime monitoring ping URL) ----------------------------------
# Pinged every 5 minutes while the process runs, and once immediately at
# startup. Its ABSENCE is the alarm, so set your monitor's grace period to
# ~15 minutes (three missed pings). This says "the box is still there" and
# nothing else: "has it stopped detecting?" is DEADMAN_HOURS below.
#BIRDNET_HEARTBEAT_URL=
# --- Credentials as mounted files --------------------------------------------
# Each of the five variables below takes the *path* of a file whose contents
# become the value of the variable it is named after. This is how Docker
# (`secrets:` → /run/secrets/<name>) and Kubernetes (a projected secret volume)
# hand a credential to a process without putting it in the environment, where
# `docker inspect`, /proc/<pid>/environ and anything that logs its own
# environment can all read it.
#
# Rules, all of them reported in the journal at startup:
# * The direct value wins. Set both and the file is not read.
# * A file that cannot be read, or that is empty, leaves the feature OFF —
# and says so at error level, because a station silent for that reason
# looks exactly like one that was told to be quiet.
# * Leading and trailing whitespace is trimmed; a newline at the end of the
# file is expected and harmless. Inner newlines are kept, so a
# NOTIFY_URLS file may list one URL per line.
# * A value read from a file is NOT copied into the settings table, so it
# stays out of the database and out of every backup of it.
#BIRDNET_NOTIFY_URLS_FILE=/run/secrets/birdnet_notify_urls
#BIRDNET_APPRISE_URL_FILE=
#BIRDNET_BIRDWEATHER_TOKEN_FILE=
#BIRDNET_MQTT_PASSWORD_FILE=
#BIRDNET_HEARTBEAT_URL_FILE=
#BIRDNET_WEATHER_API_KEY_FILE=
#BIRDNET_EBIRD_API_KEY_FILE=
# --- Capture watchdog (expert) -------------------------------------------------
# Every source is supervised independently: a dead subprocess is restarted with
# capped exponential backoff, and one that is alive but has stopped writing
# segments is caught as a silent stall and restarted the same way. The timings
# below are that supervisor's, and the defaults are what the station has always
# used — leave them alone unless a specific site misbehaves. A value outside its
# range is clamped and the adjustment is logged, so a station never runs on
# timings its config file does not describe.
#
# NOTE: none of these is "how long may the station go without a detection" —
# that is BIRDNET_DEADMAN_HOURS above. A microphone writes segments through
# hours of silence, so no amount of quiet trips the stall threshold.
#
# There is deliberately NO maximum retry count. A field sensor unreachable for
# six hours must still be reachable on hour seven, and a supervisor that has
# given up is a station that is silently not recording.
#
# How often every source is reconciled, in seconds. 1-300.
#BIRDNET_WATCHDOG_CHECK_SECS=2
# Segment-durations of missing output before a live process is called stalled.
# 2-60. Raise it for a camera that legitimately pauses.
#BIRDNET_WATCHDOG_STALL_SEGMENTS=4
# Floor under the stall threshold, in seconds, so short segments do not make
# the verdict hair-triggered. 10-3600.
#BIRDNET_WATCHDOG_STALL_FLOOR_SECS=120
# First retry delay after a source is found dead, in seconds. 1-60.
#BIRDNET_WATCHDOG_BACKOFF_BASE_SECS=2
# Ceiling on the retry delay, in seconds. 2-3600. A ceiling below the first
# delay is raised to it — a shrinking backoff is nobody's intent.
#BIRDNET_WATCHDOG_BACKOFF_CAP_SECS=60
# How long a source may be down before the loud journal warning, in seconds.
# 10-86400.
#BIRDNET_WATCHDOG_DOWN_WARN_AFTER_SECS=120
# How often to repeat that warning, in seconds. 30-86400. This is also the
# repeat cadence for the "flapping" warning.
#BIRDNET_WATCHDOG_DOWN_WARN_EVERY_SECS=300
# --- Saved clips: loudness -----------------------------------------------------
# Normalise each saved clip to this integrated loudness (ITU-R BS.1770), in
# LUFS. Unset — the default — writes clips at capture level.
#
# What it fixes: a gallery of clips recorded at different levels sends the
# listener to the volume control between every one, and a quiet clip at the end
# of a playlist gets missed. Measuring each clip and applying one gain is the
# standard remedy.
#
# Applied to the SAVED CLIP ONLY. The audio the classifier hears is untouched,
# so this cannot move a confidence score or make two stations' thresholds mean
# different things.
#
# Useful values: -14 (loud, streaming), -18 (recommended), -23 (EBU R128
# broadcast). Anything outside -40..-6 is ignored with a log line rather than
# clamped, because a mistyped target should not quietly normalise every clip to
# a level nobody chose. Also settable from Settings -> Audio Capture.
#BIRDNET_CLIP_TARGET_LUFS=-18
# Sample peak no sample may pass after the gain, in dBFS. Must be in -12..0.
# This is a SAMPLE peak, not an ITU true peak: the station does not oversample
# to find inter-sample peaks, and the default leaves about a decibel of headroom
# for them. A clip that is quiet but peaky — one loud wing-beat over a distant
# song — is turned up only as far as this allows and does not reach the target.
#BIRDNET_CLIP_PEAK_CEILING_DBFS=-1
# --- Storage / retention -----------------------------------------------------
# wav | mp3 | flac | ogg (non-WAV needs ffmpeg or sox in the image)
#BIRDNET_AUDIO_FORMAT=wav
# Maximum extracted recordings kept per species (0 = unlimited).
BIRDNET_MAX_FILES_PER_SPECIES=0
# Reclaim the audio of detections older than this many days (blank/0 = keep
# audio forever, the default). Detection records are always kept — only the
# sound file goes — and locked clips are exempt.
#BIRDNET_CLIP_RETENTION_DAYS=
# Frequency shift applied to saved clips, in Hz (0 = disabled). A true shift:
# -3000 plays an 8 kHz song at 5 kHz and leaves the clip's length alone. Negative
# lowers, which is the direction that helps high-frequency hearing loss (try
# -3000); positive raises. Needs ffmpeg 4.3+ (afreqshift); without it the clip
# is saved unshifted and a warning says so.
#BIRDNET_FREQ_SHIFT_HZ=0
# Comma-separated paths the disk manager must never auto-purge.
#BIRDNET_DISK_EXCLUDE=
# Disk-usage % at which recordings start being purged (blank = 95). The purge
# takes from the most-recorded species first (lowest confidence, then oldest,
# within it); locked clips are never purged, whatever this is set to.
#BIRDNET_DISK_PURGE_THRESHOLD=
# Clips every species keeps when that purge runs (blank = 5, 0 = no floor):
# a species with this many clips or fewer is never touched, so the one clip
# of a rare bird outlives the thousandth of a common one.
#BIRDNET_PURGE_SPECIES_FLOOR=
# Seconds a raw capture segment is kept in the temporary streaming folder
# before being cleared (blank = 600). Only needed until the detector has read
# it; the default is far longer than that.
#BIRDNET_STREAM_RETENTION_SECS=
# Hard ceiling in MB on that same streaming folder; oldest segments go first
# (blank = 512). It is usually RAM-backed, so this stops a busy station
# filling it.
#BIRDNET_STREAM_MAX_MB=
# Keep one raw capture segment in N on the data disk, under <recordings>/raw,
# before the streaming folder drains it. Blank or 0 keeps none (the default);
# a value of one keeps every segment. This is what a season can be
# re-analysed from under a new model. Budget it: every segment is about 8 GB
# a day per source, one in ten about 0.8 GB. The disk-full purge takes kept
# raw audio before any clip.
#BIRDNET_RAW_AUDIO_KEEP_EVERY=
# Directory with custom species images (overrides Wikipedia cache).
#BIRDNET_CUSTOM_IMAGE_DIR=
# Where species photographs come from: `wikipedia` (default), `flickr` or
# `avicommons`.
#
# Wikipedia needs no key and covers most species, but for a long tail it has no
# photograph at all — and for many others it has a museum skin, an egg, or a
# range map.
#
# `avicommons` needs no key: it uses https://avicommons.org's cropped bird
# photographs, but only the CC0, CC BY and CC BY-SA ones (about a quarter of the
# species), with Wikipedia behind it for the rest. It downloads the collection's
# 1.6 MB index once a month, not on every page.
#
# `flickr` searches Flickr first and falls back to Wikipedia when Flickr has
# nothing, so choosing it can only add coverage. It needs an API key, free from
# https://www.flickr.com/services/apps/create/apply/
#
# Only the Creative Commons licences that permit commercial use are requested
# (CC BY, CC BY-SA, CC BY-ND, CC0, Public Domain, Flickr Commons, US Government
# Work). Your station's page may be published, and the station cannot know
# whether that counts as commercial, so the permissive set is the safe one.
# Every photograph is shown with its photographer's name and a link to the
# Flickr page, which is what those licences require.
#
# FLICKR_FILTER_EMAIL restricts the search to one photographer's photostream —
# set it to your own Flickr account's email address to show your own
# photographs of the birds your own station heard. Wikipedia still fills in the
# species you have not photographed.
#
# The key is masked in support bundles; so is the local part of the address.
# Config-file keys only: IMAGE_PROVIDER, FLICKR_API_KEY and FLICKR_FILTER_EMAIL
# are read from birdnet.conf, not from the environment, so they have no effect in
# Docker (which ships no config file).
#IMAGE_PROVIDER=wikipedia # or flickr (needs a key) or avicommons (no key)
#FLICKR_API_KEY=
#FLICKR_FILTER_EMAIL=
# =============================================================================
# ADVANCED — internal container paths, custom models, local builds.
# Most users should leave this entire section blank.
# =============================================================================
# Container paths (correct by default; change only if you remap the volume).
BIRDNET_LISTEN=0.0.0.0:8502
BIRDNET_WATCH_DIR=/data/recordings
# Species image cache. Defaults to an `images/` dir beside the database when
# unset (stock installs show photos out of the box); set empty to disable.
BIRDNET_IMAGE_CACHE_DIR=/data/cache
BIRDNET_ANALYTICS_DB=/data/analytics.db
BIRDNET_MODEL_DIR=/data/model
# Upgrade without the pre-migration backup. A few migrations rewrite stored
# history, and the station copies the database first; that copy needs as much
# free space as the database itself. On a disk too full for it the upgrade
# stops and the log names this variable. Setting it (to anything but empty or
# 0) lets the rewrite run with no backup: the previous values cannot be
# recovered. Leave it commented unless the log told you to.
#BIRDNET_SKIP_MIGRATION_BACKUP=1
# DuckDB memory cap for behavioural-analytics queries.
#
# LEAVE THIS UNSET unless you have measured your own station. Unset, the pool is
# sized to a quarter of whatever this process may actually use — the smaller of
# physical RAM and any cgroup limit (the systemd unit's MemoryMax=1G, or a
# container's) — capped at 256MB and floored at 64MB. On the shipped unit that
# comes to exactly 256MB, the value this line used to pin; on a 512MB board it
# comes to 128MB instead of handing one subsystem half the machine, which is
# what this line did. Below a 256MB ceiling the floor cannot be met and
# analytics is refused at startup with an explanation, rather than being
# OOM-killed mid-query.
#
# Set it and it is used verbatim, on any machine — including one the sizing
# would have refused. `birdnet-behavior --doctor` reports which of those
# happened under "Analytics memory".
#
# (This line was uncommented until 0.16. A container using this file as its .env
# therefore pinned 256MB on every board, which is the case the sizing exists
# for.)
#BIRDNET_DUCKDB_MEMORY_LIMIT=256MB
# Threads the analytics engine may use. Unset: the CPUs this process may run on,
# less the two the bird classifier uses, and at least one (2 on a Raspberry Pi 4).
# DuckDB's own default is every core, which slows classification while a
# dashboard query runs.
#BIRDNET_DUCKDB_THREADS=2
# Path of the birdnet.conf file the binary reads (BirdNET-Pi-compatible keys).
# Docker ships no config file; everything here is an environment variable.
#BIRDNET_CONFIG=/etc/birdnet/birdnet.conf
# Seconds a starting instance waits for a previous one to release the data
# directory before refusing to start (default 30, matching the unit's
# TimeoutStopSec). Only one instance may use a data directory at a time.
#BNB_INSTANCE_LOCK_GRACE_SECS=30
# Test-suite only: with this set, a test that would skip because the DuckDB
# behavioral extension could not be fetched fails instead. Not a station
# setting.
#BIRDNET_REQUIRE_LIVE_EXTENSION=1
# Bring-your-own model (leave blank — the entrypoint installs the classifier
# above on first run; set these ONLY if you have a custom model). With
# BIRDNET_SKIP_MODEL_DOWNLOAD=1 nothing is installed at all: stage
# birdnet-v24.onnx, birdnet-v24_labels.txt and birdnet-v24-range-v2.onnx in
# /data/model yourself (`birdnet-behavior --install-model birdnet-v24`, run on a
# machine with internet, writes exactly those files). BIRDNET_MODEL_BASE and
# BIRDNET_ZENODO_BASE (air-gapped mirrors) apply to the V3.0 preview only: V2.4 is
# installed by the binary, which has no mirror setting.
#BIRDNET_SKIP_MODEL_DOWNLOAD=
#BIRDNET_MODEL=
#BIRDNET_LABELS=
# Optional directory of {code}_labels.txt files (`Scientific name_Common name`,
# one per line) whose names replace the built-in ones for that language.
#BIRDNET_LABELS_DIR=
# (BIRDNET_METADATA_MODEL / BIRDNET_METADATA_LABELS are documented under
# "Species occurrence filtering" above — they are not part of the classifier
# download, and nothing auto-downloads them.)
# Host UID whose PulseAudio/PipeWire socket the pulse overlay mounts
# (run `id -u` on the host if your UID is not 1000).
PULSE_UID=1000
# Local builds only — pass `BUILD_FEATURES=analytics` to `docker compose build`
# to compile the analytics variant yourself instead of pulling it from GHCR.
BUILD_FEATURES=
CLI flags — birdnet-behavior --help
BirdNet-Behavior: Real-time acoustic bird classification with DuckDB behavioral analytics
Usage: birdnet-behavior [OPTIONS]
Options:
-c, --config <CONFIG>
Path to configuration file
[env: BIRDNET_CONFIG=]
[default: /etc/birdnet/birdnet.conf]
--listen <LISTEN>
Web server listen address. Defaults to all interfaces so the dashboard is reachable on the LAN out of the box; the `/admin` panel is gated by a password (set `CADDY_PWD`). Bind `127.0.0.1:8502` to restrict to this host
[env: BIRDNET_LISTEN=]
[default: 0.0.0.0:8502]
--tls-mode <TLS_MODE>
How the dashboard terminates TLS: `off`, `self-signed`, or `manual`.
`off` (the default) serves plain HTTP, exactly as every release before this one did.
`self-signed` generates a small local CA and a server certificate it signs, both kept in `--tls-dir` and rotated before they expire. Browsers warn until you import the CA file once (the startup log prints its path); after that they stop, and a later certificate rotation does not bring the warning back.
`manual` serves `--tls-cert` and `--tls-key`. Both are re-read when they change on disk, so an ACME client renewing them in the small hours is picked up without a restart.
[env: BIRDNET_TLS_MODE=]
[default: off]
--tls-cert <TLS_CERT>
Certificate chain (PEM) for `--tls-mode manual`
[env: BIRDNET_TLS_CERT=]
--tls-key <TLS_KEY>
Private key (PEM) for `--tls-mode manual`
[env: BIRDNET_TLS_KEY=]
--tls-listen <TLS_LISTEN>
Where HTTPS listens. Defaults to `--listen`'s host on port 8503.
Set this equal to `--listen` to serve **only** HTTPS on the one port; otherwise plain HTTP keeps answering on `--listen` (see `--tls-redirect`).
[env: BIRDNET_TLS_LISTEN=]
--tls-redirect
Answer plain HTTP with a redirect to HTTPS instead of serving the app.
Ignored when HTTPS shares the one socket with `--listen`, because then there is no plain port to redirect from.
[env: BIRDNET_TLS_REDIRECT=]
--tls-hostnames <TLS_HOSTNAMES>
Names the generated certificate should cover, comma-separated.
Defaults to `localhost`, this machine's hostname and `<hostname>.local`, the bound address, and `127.0.0.1` — the three ways a station is normally reached. Adding a name here regenerates the certificate.
[env: BIRDNET_TLS_HOSTNAMES=]
--tls-dir <TLS_DIR>
Directory for the generated CA and server certificate.
Defaults to a `tls` directory beside the database.
[env: BIRDNET_TLS_DIR=]
--tls-days <TLS_DAYS>
How many days a generated server certificate is valid for.
The local CA that signs it lives for ten years regardless, so rotating the server certificate never invalidates a trust-store import.
[env: BIRDNET_TLS_DAYS=]
[default: 397]
--web-only
Run only the web server (skip analysis daemon)
--check-db
Run database integrity check and exit
--apply-config <FILE>
Validate FILE, install it as the configuration, restart the service, and exit.
The safe way to change the configuration on a running station: a file with errors is refused and nothing changes; a good one is installed behind a timestamped backup of the previous file and the service is restarted. Should the station fail to start on it anyway, it runs on the last configuration a start succeeded on and says so at /station.
--backup-db
Create database backup and exit
--rebuild-species-summary
Recompute the maintained species summary from the detections, and exit.
`species_summary` holds the per-species totals the species list and the per-species charts read, kept up to date on write so those screens do not re-aggregate the whole detection history on every load. It is derived data: rebuilding it cannot lose anything.
Run this if `--doctor` reports the summary disagreeing with the detections. Nothing else should need it.
--doctor
Run the preflight diagnostic and exit.
Validates the configuration, audio source, model file, database integrity, disk space, and tool dependencies. Returns: - 0 — all checks passed - 1 — at least one warning (system will run, features degraded) - 2 — at least one error (system will not work until fixed)
Run after install and any time the system behaves unexpectedly.
[alias: --preflight]
--doctor-gate
Run the doctor as the service's start gate, then exit.
The same checks and report as `--doctor`, but only a failure the station cannot run past — an unreadable config file, an invalid listen address, an unwritable database directory, an unusable HTTPS setup — exits 2. Any other failure exits 1: the station starts, runs what it can, and `/admin/doctor` shows the same report. The systemd unit's `ExecStartPre` uses this; `--doctor` keeps its own exit codes.
--install-model <ID>
Install a classifier from the built-in catalogue, then exit (`G-10`).
`--install-model perch-v2`. Pass `list` to print the catalogue without installing anything.
In the foreground rather than behind an API call, deliberately: this is a 400 MB download that takes hours on the uplink a field station has, and an operator should be able to watch it, interrupt it, and see it fail. The download is checked against a sha256 compiled into this binary — a file that does not match is deleted rather than installed, and nothing lands under a name the station would load until it has verified.
--extraction-length <EXTRACTION_LENGTH>
Seconds of audio saved around each detection (default 6).
BirdNET-Pi's `EXTRACTION_LENGTH`. Longer clips are easier to verify by ear and cost proportionally more disk; shorter ones can clip the end of a song. Also settable on `/admin/settings`.
[env: BIRDNET_EXTRACTION_LENGTH=]
--log-level <LEVEL>
Global log level: `trace`, `debug`, `info`, `warn`, `error`, `off`.
Also settable as `LOG_LEVEL` in the config file. `RUST_LOG` overrides both when it is set, so a developer's export still wins.
[env: BIRDNET_LOG_LEVEL=]
--log-modules <SPEC>
Per-subsystem log levels, e.g. `audio=debug,web=warn`.
Subsystem names are operator-facing rather than Rust module paths: `audio`, `detection`, `web`, `db`, `integrations`, `analytics`. An explicit path (`birdnet_core::inference=trace`) is accepted too.
This is the equivalent of BirdNET-Pi's per-service log levels: one process rather than eight services, so the knob is per subsystem. Unknown names are reported at startup rather than ignored. Also settable as `LOG_MODULES` in the config file.
[env: BIRDNET_LOG_MODULES=]
--support-bundle [<PATH>]
Collect a support bundle and exit.
Writes a `.tar.gz` holding the diagnostic report (both JSON and text), the station's version, a **redacted** copy of the configuration, and the last 2000 journal lines — everything a maintainer asks for in the first three replies to a bug report, in one file.
Passwords, tokens and URL credentials are masked. Read it before posting it anywhere public all the same.
Defaults to `birdnet-support.tar.gz` in the working directory; pass a path to write it elsewhere.
--doctor-json
Run the preflight diagnostic and emit a single-line JSON document.
Same checks and exit codes as `--doctor`, but the output is a machine-readable JSON object with `summary` and `checks` fields. Use for monitoring scripts (Nagios / Zabbix / Prometheus textfile collector / Home Assistant command sensor).
--channel-report
Report what a stereo microphone is actually delivering to the model.
Records a few seconds from the configured ALSA device and prints, for the same audio: the level of each channel, the inter-channel delay (and the capsule spacing that implies), and what each way of reducing two channels to one would hand BirdNET.
The model takes a single channel, so two must become one somewhere. Today that is a plain average, which is harmless for coincident capsules and a comb filter for spaced ones — at half a period of delay it cancels almost the entire signal. Whether a given station sits in that case depends on its microphone and cannot be answered anywhere but on the station.
STOP THE SERVICE FIRST. An ALSA capture device is exclusive, so this cannot open a microphone the running station is already holding: `sudo systemctl stop birdnet-behavior`, run the report, start it again.
--channel-report-secs <CHANNEL_REPORT_SECS>
Seconds of audio to record for `--channel-report` (default 5).
Longer is steadier. Run it while birds are actually singing: the cancellation is direction-dependent, so ambient noise alone can look benign on a microphone that loses badly on real song.
[default: 5]
--migration-report
Report what a pending migration would do to existing detections, and exit.
Most migrations only change the schema around the data. A few rewrite rows that are already on disk, and those destroy their own input — after migration 24 folds each detection's chunk offset into its timestamp, nothing records what that timestamp used to be. This shows how many rows would move, by how much, how many roll onto the next day, and which are left alone, before any of it happens.
Opens the database read-only and changes nothing, so it is safe to run on a live station. The rewrite itself runs on the next normal start, which first copies the database to `<db>.pre-migration-<version>.backup`.
--import-birdnet-pi-conf <PATH>
Read a BirdNET-Pi installation's settings and show what each becomes here.
Give the BirdNET-Pi folder (`~/BirdNET-Pi`) or its `birdnet.conf`. Reads that file and, beside it, `apprise.txt`, `body.txt` and the three species list files, and prints every key with what it becomes, what is converted on the way and what has no equivalent. Changes nothing unless `--apply-import` is also given, which writes the settings through the same checks as the Settings page. The same as Station, Data, in the browser. Credentials are never printed.
--import-birdnet-pi-clips <PATH>
Bring BirdNET-Pi's recordings across, for the detections already imported.
Give BirdNET-Pi's clip folder: `~/BirdSongs`, or the `Extracted` or `By_Date` folder inside it. Looks only; add `--apply-import` to do it, and `--clips-mode` to choose copy, link or move. Refuses a copy that would take the disk past the purge threshold.
--clips-mode <MODE>
What `--import-birdnet-pi-clips` does with each recording: `copy` (BirdNET-Pi keeps its own), `hardlink` (no extra space; same disk only), `move` (BirdNET-Pi loses them) or `leave` (count them, change nothing)
[default: copy]
--apply-import
Do what `--import-birdnet-pi-conf` and `--import-birdnet-pi-clips` show. Without it they change nothing
--fix
Attempt safe automatic repairs, then run the diagnostic.
Implies `--doctor` (use with `--doctor-json` for machine-readable output). Repairs are idempotent and never destructive — currently they create any missing configured directories (the recordings/watch directory and the image-cache directory), the most common cause of "service runs but nothing is recorded" (e.g. after a tmpfs reset on reboot). The diagnostic then runs as usual and reflects the repaired state. Anything that needs root (ownership, packages) is reported, not changed.
--model <MODEL>
Path to the ONNX model file (overrides config)
[env: BIRDNET_MODEL=]
--labels <LABELS>
Path to the species labels file (overrides config)
[env: BIRDNET_LABELS=]
--watch-dir <WATCH_DIR>
Directory to watch for new audio files (overrides config)
[env: BIRDNET_WATCH_DIR=]
--process-existing
Process audio files already present in watch directory on startup
--analytics-db <ANALYTICS_DB>
Path to the `DuckDB` analytics database file.
Analytics is on by default at `<database>.duckdb` beside the SQLite file; this overrides the path. Pass an empty value (`--analytics-db ""` or `BIRDNET_ANALYTICS_DB=`) to run without `DuckDB` at all.
[env: BIRDNET_ANALYTICS_DB=]
--refresh-extension
Reinstall the behavioral `DuckDB` extension and exit.
Force-downloads the latest `behavioral` build for the bundled `DuckDB` version from the community registry, loads it to verify, then exits. Requires `--analytics-db` (or `ANALYTICS_DB_PATH`) and network access.
--verify-extension
Verify the behavioral `DuckDB` extension actually loads, and exit.
Opens a throwaway `DuckDB` database, loads the extension through the normal three-stage fallback, and reports the engine version, the extension version, and what the build-time embedded copy targets. Exits 0 when the extension loads and non-zero when it does not.
It then runs the date-window query every dashboard opens with. That covers the other extension a station needs, `icu`, which is where `CURRENT_DATE` lives — and which DuckDB otherwise autoinstalls into `$HOME/.duckdb`, a path the shipped systemd unit mounts read-only.
Unlike `--refresh-extension` this needs no network and writes nothing you care about, so it is the check to run on a station whose analytics pages are empty. Run it with networking disabled to prove the *offline* guarantee specifically: with no network neither the extension cache nor the community registry can satisfy either load, so only the embedded copies can.
--offline
Make no unsolicited outbound connections.
Turns off every network call the station would otherwise make on its own: the daily update check against `api.github.com` and the Wikipedia species-image downloads. Integrations you configure explicitly — Apprise, `BirdWeather`, MQTT, SMTP, the heartbeat ping, the weather poll — are left alone, because asking for one is a decision the operator already made.
For metered or cellular links, air-gapped deployments, and institutional review, where "which hosts does this contact?" needs one answer rather than a per-feature audit. See `docs/book/getting-started/configuration.md` for the full egress list.
[env: BIRDNET_OFFLINE=]
--private-mode
Put the whole station behind the sign-in, not only the admin panel.
By default viewing is open: the dashboard, the read API, the live audio stream and both `WebSockets` are served to anyone who can reach the port. Behind a tunnel or a port forward that is the internet. With this set, everything needs a session except the sign-in form, its assets, the health probe and what `--public-access` names. A private station with no admin password (`CADDY_PWD`) answers 503 to everything else rather than falling open. Config file: `PRIVATE_MODE`.
[env: BIRDNET_PRIVATE_MODE=]
--public-access <PUBLIC_ACCESS>
What stays open on a private station: a comma-separated list of `live_audio` (the live audio, MP3 and HLS, and the live spectrogram), `share` (operator-minted `/r/<token>` links) and `metrics` (`/api/v2/metrics`).
No effect without `--private-mode`. An unknown name is reported and skipped. Config file: `PUBLIC_ACCESS`.
[env: BIRDNET_PUBLIC_ACCESS=]
--no-update-check
Skip the daily check for a new release.
The station otherwise contacts `api.github.com` 60 seconds after start and every 24 hours after that, purely to log whether a newer version exists. It never installs anything on its own — updates are applied only from the admin panel. Implied by `--offline`.
[env: BIRDNET_NO_UPDATE_CHECK=]
--apprise-url <APPRISE_URL>
Apprise notification server URL (e.g., `http://localhost:8000`)
[env: BIRDNET_APPRISE_URL=]
--notify-urls <NOTIFY_URLS>
Notification URLs delivered in-process, comma- or newline-separated.
Uses Apprise's URL syntax — `discord://{id}/{token}`, `tgram://{bot_token}/{chat_id}`, `ntfy://{topic}`, `gotify://{host}/{token}`, `pover://{user}@{token}`, `slack://{a}/{b}/{c}`, `jsons://{host}/{path}` — but sends them directly, so no Apprise installation is involved. A scheme without a native sender is reported once at startup and needs `--apprise-config`.
[env: BIRDNET_NOTIFY_URLS=]
--notify-rate-per-minute <NOTIFY_RATE_PER_MINUTE>
Ceiling on notifications per destination per minute; `0` disables it.
About the services' limits rather than ours: Discord allows five requests a second per webhook, Telegram about thirty, and Pushover ten thousand messages a month. Config-file key: `NOTIFY_RATE_PER_MINUTE`.
[env: BIRDNET_NOTIFY_RATE_PER_MINUTE=]
--notify-confidence <NOTIFY_CONFIDENCE>
Minimum confidence threshold for Apprise notifications (0.0–1.0)
[env: BIRDNET_NOTIFY_CONFIDENCE=]
[default: 0.8]
--deadman-hours <DEADMAN_HOURS>
Hours of zero detections before the deadman watchdog raises an alert.
The end-to-end "is the station actually detecting?" check: when no detection has been recorded for this many hours, a loud warning is logged and (if Apprise is configured) a notification is sent — once per quiet episode, with a recovery notice when detections resume. `0` disables alerting; the freshness gauge (`birdnet_detection_silence_seconds`) is exported regardless. Defaults to 24; config-file key: `DEADMAN_HOURS`.
[env: BIRDNET_DEADMAN_HOURS=]
--station-health-alerts <STATION_HEALTH_ALERTS>
Alert on operational faults the detection deadman cannot see.
The deadman answers "is the station detecting at all?". This answers the questions that leave it quiet: one of several microphones down while the rest keep working, a disk full enough that recordings are being purged, a CPU at its throttling temperature, or a backup / integrity check that has not completed in weeks. Each alerts once per episode with a recovery notice, like the deadman. Defaults to on; config-file key: `STATION_HEALTH_ALERTS`.
[env: BIRDNET_STATION_HEALTH_ALERTS=]
[possible values: true, false]
--birdweather-token <BIRDWEATHER_TOKEN>
`BirdWeather` station token for uploading detections
[env: BIRDNET_BIRDWEATHER_TOKEN=]
--latitude <LATITUDE>
Station latitude for `BirdWeather` uploads
[env: BIRDNET_LATITUDE=]
--longitude <LONGITUDE>
Station longitude for `BirdWeather` uploads
[env: BIRDNET_LONGITUDE=]
--image-cache-dir <IMAGE_CACHE_DIR>
Directory for caching species images from Wikipedia.
Defaults to an `images/` directory beside the database when unset, so a stock install shows species photos out of the box. Pass an empty value (`--image-cache-dir ""`) to disable image caching entirely — no Wikipedia fetches, e.g. for air-gapped deployments.
[env: BIRDNET_IMAGE_CACHE_DIR=]
--alsa-device <ALSA_DEVICE>
ALSA device for microphone capture (e.g., `plughw:1,0`).
For a single microphone. Use `--alsa-devices` to capture from several local mics at once.
[env: BIRDNET_ALSA_DEVICE=]
--alsa-devices <ALSA_DEVICES>
ALSA devices for multi-microphone capture (semicolon-separated, or repeat the flag).
Each device gets its own independent capture pipeline with filenames prefixed `MIC_1-`, `MIC_2-`, etc. (and matching per-source health metrics). Overrides `--alsa-device` when set. A semicolon — not a comma — separates devices because ALSA names themselves contain commas (e.g. `plughw:1,0`). Config-file equivalent: `ALSA_CARDS` (semicolon-separated); `ALSA_CARD` remains the single-mic key.
[env: BIRDNET_ALSA_DEVICES=]
--pipewire-device <PIPEWIRE_DEVICE>
PipeWire/PulseAudio source for microphone capture.
Uses `ffmpeg -f pulse` which works with both native `PulseAudio` and `PipeWire` (via `pipewire-pulse`). Leave empty or use `default` for the system default source. Takes precedence over `--alsa-device` when set. BirdNET-Pi equivalent: ALSA device pointing to a `PulseAudio` sink.
[env: BIRDNET_PIPEWIRE_DEVICE=]
--rtsp-url <RTSP_URL>
Network stream URL for audio capture (e.g., `rtsp://camera.local:554/stream`).
Also `http(s)://` (Icecast, HLS), `rtmp://`, `udp://` (MPEG-TS) and `srt://`. For a single stream. Use `--rtsp-urls` for multiple streams.
[env: BIRDNET_RTSP_URL=]
--rtsp-urls <RTSP_URLS>
Comma-separated network stream URLs for multi-stream capture.
Each URL gets its own independent capture pipeline with filenames prefixed `RTSP_1-`, `RTSP_2-`, etc. Overrides `--rtsp-url` if set.
[env: BIRDNET_RTSP_URLS=]
--segment-duration <SEGMENT_DURATION>
Duration of each recording segment in seconds (default: 15)
[env: BIRDNET_SEGMENT_DURATION=]
[default: 15]
--recording-schedule <RECORDING_SCHEDULE>
Recording schedule mode: "all-day" (24/7), "solar" (sunrise-to-sunset), or "fixed:HH:MM-HH:MM" (e.g., "fixed:06:00-20:00")
[env: BIRDNET_RECORDING_SCHEDULE=]
[default: all-day]
--night-inhibit
Inhibit recording during night hours (requires --latitude and --longitude)
[env: BIRDNET_NIGHT_INHIBIT=]
--twilight-offset <TWILIGHT_OFFSET>
Minutes offset from sunrise/sunset for twilight recording (default: 30).
Applies to both ends of the day. Use `--pre-sunrise-offset` / `--post-sunset-offset` to set them independently; either one overrides this for its own end.
[env: BIRDNET_TWILIGHT_OFFSET=]
[default: 30]
--pre-sunrise-offset <PRE_SUNRISE_OFFSET>
Minutes *before* sunrise at which recording starts.
Overrides `--twilight-offset` for the morning end only. Dawn and dusk activity are rarely symmetric — the dawn chorus starts well before first light while evening song tails off quickly — so a station usually wants a longer pre-sunrise window than post-sunset one. Unset falls back to `--twilight-offset`.
[env: BIRDNET_PRE_SUNRISE_OFFSET=]
--post-sunset-offset <POST_SUNSET_OFFSET>
Minutes *after* sunset at which recording stops.
Overrides `--twilight-offset` for the evening end only. Unset falls back to `--twilight-offset`.
[env: BIRDNET_POST_SUNSET_OFFSET=]
--heartbeat-url <HEARTBEAT_URL>
Uptime-monitor URL to ping every five minutes while the station runs.
A liveness signal for an external monitor (Healthchecks.io, Uptime Kuma): its *absence* is the alarm, so it must not depend on a bird singing. Whether the station is still detecting is a different question, answered by `--deadman-hours`.
[env: BIRDNET_HEARTBEAT_URL=]
--notify-trigger <NOTIFY_TRIGGER>
Notification trigger: "each", "new-species", "new-species-daily", or "off"; several may be listed, separated by commas ("each,new-species"). `BirdNET-Pi`'s three `APPRISE_NOTIFY_*` switches are this list
[env: BIRDNET_NOTIFY_TRIGGER=]
[default: each]
--notify-species-exclude <NOTIFY_SPECIES_EXCLUDE>
Species to exclude from notifications (comma-separated scientific names)
[env: BIRDNET_NOTIFY_SPECIES_EXCLUDE=]
--notify-species-only <NOTIFY_SPECIES_ONLY>
Only notify for these species (comma-separated scientific names)
[env: BIRDNET_NOTIFY_SPECIES_ONLY=]
--notify-title-template <NOTIFY_TITLE_TEMPLATE>
Custom notification title template (supports $comname, $sciname, $confidence, etc.)
[env: BIRDNET_NOTIFY_TITLE_TEMPLATE=]
--notify-body-template <NOTIFY_BODY_TEMPLATE>
Custom notification body template (supports $comname, $sciname, $confidence, etc.)
[env: BIRDNET_NOTIFY_BODY_TEMPLATE=]
--metadata-model <METADATA_MODEL>
Path to the metadata ONNX model for species occurrence filtering.
When set, the metadata model predicts which species are likely present at the station's location and time of year, filtering out unlikely species.
[env: BIRDNET_METADATA_MODEL=]
--metadata-labels <METADATA_LABELS>
Path to the metadata model's own label file.
The metadata model and the classifier do not score the same species list — BirdNET Geomodel v3.0 covers 12 012 species where the V3.0 Global 11K classifier emits 11 560 — so the model's output positions mean nothing without the labels it shipped with. Supply them and the two are matched by scientific name.
Omit this only for a metadata model indexed identically to the classifier (a matched BirdNET pair). The station verifies that at startup and refuses a mismatched model rather than reporting one bird under another bird's name.
This file also carries each species' eBird code, which the species page's "View on eBird" link is built from; without it that link is not shown.
[env: BIRDNET_METADATA_LABELS=]
--species-aliases <SPECIES_ALIASES>
Extra scientific-name aliases for the occurrence filter: a tab-separated `legacy<TAB>canonical` file.
The geomodel and the classifier were frozen at different points in a moving taxonomy and do not always spell a species the same way. Most of those the station reconciles by itself, on the common name and the specific epithet; this file is for the pairs that share neither.
Blank lines and `#` comments are skipped, and a malformed line is skipped rather than fatal — a typo here must not take the occurrence filter off a running station.
[env: BIRDNET_SPECIES_ALIASES=]
--sf-thresh <SF_THRESH>
Species frequency threshold for the metadata model filter (0.0-1.0).
Species with occurrence probability below this threshold are filtered out. Lower values allow more species through; higher values are more restrictive.
[env: BIRDNET_SF_THRESH=]
[default: 0.03]
--privacy-threshold <PRIVACY_THRESHOLD>
Privacy filter threshold for human voice detection (0.0 = disabled).
The model's confidence for its human classes (speech, whistling, other human sounds) at or above which an analysis window is suppressed, along with the windows either side of it. Read from the model's output before the detection threshold applies, so this value binds on its own: lower suppresses more. Typical values: 0.01-0.03.
[env: BIRDNET_PRIVACY_THRESHOLD=]
[default: 0.0]
--privacy-rank <PRIVACY_RANK>
BirdNET-Pi's privacy rule: suppress an analysis window, and the windows either side of it, when a human class (speech, whistling, other human sounds) is among the model's this-many highest-scoring classes (0 = off).
Read from the model's output before the detection threshold applies, as BirdNET-Pi reads it. The default, 10, is BirdNET-Pi's own at its own default (`PRIVACY_THRESHOLD=0` gives `human_cutoff = max(10, …)`), so a station nobody has customised does not keep a clip of a conversation near its microphone. For a BirdNET-Pi `PRIVACY_THRESHOLD` of `p`, set this to `max(10, 60 × p)`. It works beside `--privacy-threshold`; either rule suppresses a window. Config key: `PRIVACY_RANK`.
[env: BIRDNET_PRIVACY_RANK=]
[default: 10]
--noise-threshold <NOISE_THRESHOLD>
Confidence at which a barking dog suppresses its audio chunk (0.0 = disabled).
`BirdNET`'s label set carries non-bird classes, and a bark near the microphone is broadband enough that the classifier also produces confident-looking scores for whatever species it most resembles. When a watched class scores at or above this, every detection in that chunk is discarded. Typical values: 0.5-0.8. Config key: `NOISE_THRESHOLD`.
[env: BIRDNET_NOISE_THRESHOLD=]
[default: 0.0]
--noise-classes <NOISE_CLASSES>
Non-bird label names the noise filter watches, comma-separated.
Defaults to `Dog`. Other classes in the label set are `Siren`, `Engine`, `Power tools`, `Fireworks`, `Gun`, `Environmental` and `Noise` — the last two score highly on ordinary quiet recordings, so watching them will suppress a great deal. Names are matched exactly (case-insensitively) against a label's common or scientific name. Config key: `NOISE_CLASSES`.
[env: BIRDNET_NOISE_CLASSES=]
--decrypt-backup <FILE>
Decrypt an offsite backup file and exit.
The counterpart to `--offsite-backup`: takes a `.bnb` file downloaded from the bucket or the SSH server and writes the plain database to `--out`. The result is an ordinary `SQLite` file — stop the service and put it in place of `birds.db`, as with any snapshot.
The passphrase comes from `OFFSITE_PASSPHRASE` (config or `BIRDNET_OFFSITE_PASSPHRASE`), never from a flag: an argument on a command line is visible in `ps` to every user on the machine. There is no recovery without it.
--out <PATH>
Where `--decrypt-backup` writes the plain database.
Refused if the file already exists: a restore that silently overwrote the database you were about to compare against would be worse than one that failed.
--offsite-backup <OFFSITE_BACKUP>
Send each weekly backup to an offsite destination: `off`, `s3` or `sftp`.
A station's rolling backups live beside its database, on the same card. That covers a corrupt page and a bad VACUUM; it does not cover the card wearing out, the enclosure flooding, or the Pi being stolen.
Everything is encrypted on the station before it leaves, and that is not configurable — so this needs `OFFSITE_PASSPHRASE`, plus the destination's own settings. **Those are config-file or environment keys only, never flags**: an argument on a command line is visible in `ps` to every user on the box and is copied into the journal by systemd. `--doctor` lists what is missing. Config key: `OFFSITE_BACKUP`.
[env: BIRDNET_OFFSITE_BACKUP=]
--confirmation-level <CONFIRMATION_LEVEL>
How much agreement from neighbouring analysis windows a species needs before it is recorded: `off`, `lenient`, `moderate`, `balanced` or `strict`.
A real bird sings across more than one window; a classifier artefact usually fires once. Each level names the fraction of the windows within six seconds that must carry the same species — 20%, 30%, 50%, 70% — rounded up, and never less than the detection itself.
**This does nothing without `--overlap`.** With no overlap a six-second neighbourhood holds two 3-second windows, and 20% or 30% of two rounds to one, which every detection already satisfies. `--doctor` reports the overlap each level needs, and the daemon warns at startup when the level it was given cannot reject anything.
Off by default: it removes rows, so switching it on puts a visible step in every chart. Config key: `CONFIRMATION_LEVEL`.
[env: BIRDNET_CONFIRMATION_LEVEL=]
[default: off]
--duplicate-interval-secs <DUPLICATE_INTERVAL_SECS>
Suppress a repeat of the same species within this many seconds (0 = disabled).
A 15-second recording is five 3-second chunks, so a bird that sings through the whole of it is recorded five times — and every count in the application is a row count, so a species that sings in long phrases outscores one that calls in short bursts for no reason but phrasing.
Off by default: switching it on changes how many rows a station records, which puts a visible step in every chart when it happens. Config key: `DUPLICATE_INTERVAL_SECS`.
[env: BIRDNET_DUPLICATE_INTERVAL_SECS=]
[default: 0]
--night-filter
Quarantine day birds heard in the middle of the night.
A blue tit "detected" at 02:30 is almost always the classifier hearing something else. Owls, nightjars, rails and bitterns are exempt — see `birdnet_core::detection::nocturnal` for the genus list, and `--night-extra-nocturnal` to add to it.
Needs station coordinates; without them there is no sunrise to compute and the filter stays off. Detections are quarantined for review, never dropped, because the taxonomy is genus-level and cannot be complete. Config key: `NIGHT_FILTER`.
[env: BIRDNET_NIGHT_FILTER=]
--night-margin-mins <NIGHT_MARGIN_MINS>
Minutes after sunset and before sunrise before the night window opens.
Birds sing through dusk and start again well before sunrise, so a window that began at sunset would quarantine the evening chorus. Config key: `NIGHT_MARGIN_MINS`.
[env: BIRDNET_NIGHT_MARGIN_MINS=]
[default: 60]
--night-extra-nocturnal <NIGHT_EXTRA_NOCTURNAL>
Extra genera or scientific names always allowed at night, comma-separated.
A station recording nocturnal flight calls — migrating thrushes and warblers calling as they pass overhead — should either leave `--night-filter` off or name those genera here. Config key: `NIGHT_EXTRA_NOCTURNAL`.
[env: BIRDNET_NIGHT_EXTRA_NOCTURNAL=]
--overlap <OVERLAP>
Analysis window overlap in seconds (0.0-2.9, default 0.0).
Controls how much consecutive analysis windows overlap (the model's window: 3 s for BirdNET V2.4, 4.5 s for the bundled V3.0). Higher overlap increases sensitivity at the cost of more CPU time. BirdNET-Pi equivalent: OVERLAP config option.
[env: BIRDNET_OVERLAP=]
[default: 0.0]
--site-name <SITE_NAME>
Custom site name displayed in page titles and header.
Replaces the default "BirdNet-Behavior" branding in the web UI.
[env: BIRDNET_SITENAME=]
--lang <LANG>
Language to show species common names in (e.g. `de`, `fr`, `ja`, `zh_CN`).
Only what is shown changes: the names stored with detections stay the classifier's. 35 languages are built in (`/api/v2/languages` lists them with their coverage of the installed classifier); species a language has no name for keep the classifier's. Default: "en" (English).
[env: BIRDNET_LANG=]
[default: en]
--labels-dir <LABELS_DIR>
Directory of optional species-name files that replace the built-in names.
Files are named `de_labels.txt`, `fr_labels.txt` and so on (or sit in a `labels_l18n/` subdirectory), one `Scientific name_Common name` per line. Not needed for the built-in languages.
[env: BIRDNET_LABELS_DIR=]
--info-site <INFO_SITE>
eBird/AllAboutBirds species info links: "ebird", "allaboutbirds", or "none"
[env: BIRDNET_INFO_SITE=]
[default: ebird]
--audio-format <AUDIO_FORMAT>
Audio format for extracted detection clips: "wav", "mp3", "flac", or "ogg".
Non-WAV formats require ffmpeg or sox to be installed. BirdNET-Pi equivalent: AUDIOFMT config option.
[env: BIRDNET_AUDIO_FORMAT=]
[default: wav]
--max-files-per-species <MAX_FILES_PER_SPECIES>
Maximum number of extracted recordings kept per species (0 = unlimited).
When set, the oldest files beyond this limit are deleted automatically. `BirdNET-Pi` equivalent: `MAX_FILES_SPECIES` config option.
[env: BIRDNET_MAX_FILES_PER_SPECIES=]
[default: 0]
--disk-exclude <DISK_EXCLUDE>
Comma-separated paths to exclude from disk usage monitoring.
Files under these paths are never auto-purged.
[env: BIRDNET_DISK_EXCLUDE=]
--clip-retention-days <CLIP_RETENTION_DAYS>
Reclaim the audio of detections older than this many days (0 = keep audio forever, the default).
The age-based half of retention, alongside `--max-files-per-species` and the disk-full purge. Locked clips are exempt, and the detection records themselves are always kept — counts, species lists, trends and exports are unaffected; only the audio is reclaimed.
[env: BIRDNET_CLIP_RETENTION_DAYS=]
[default: 0]
--disk-purge-threshold <DISK_PURGE_THRESHOLD>
Disk-usage percentage at which recordings start being purged.
The safety net that keeps a 24/7 station from filling its card: once the data disk crosses this, clips are deleted from the most-recorded species first (lowest confidence, then oldest, within it), no species is taken below `--purge-species-floor`, and locked clips are never touched. `BirdNET-Pi` equivalent: `PURGE_THRESHOLD`. What happens at the threshold is the config key `FULL_DISK` (`purge`, the default, or `keep`, which stops recording and deletes nothing), as in BirdNET-Pi.
`0` leaves the resolution to the config file / admin settings, then the 95 % default — the flag is only "set" when given, so it never silently overrides a value chosen in the UI.
[env: BIRDNET_DISK_PURGE_THRESHOLD=]
[default: 0]
--purge-species-floor <PURGE_SPECIES_FLOOR>
Clips every species keeps when the disk-full purge runs.
The purge takes from the most-recorded species first, so the single clip of the year's rarest bird is the last thing to go; this floor makes it never go: a species with this many clips or fewer is not touched, however full the disk is. `0` removes the floor. Config file: `PURGE_SPECIES_FLOOR`; unset, 5.
[env: BIRDNET_PURGE_SPECIES_FLOOR=]
--stream-retention-secs <STREAM_RETENTION_SECS>
Seconds a raw capture segment is kept in the transient stream directory.
Raw segments are read by the detector and never needed again; draining them is what keeps the RAM-backed stream dir from filling. Far longer than the pipeline needs, so an unprocessed segment is never removed. `0` = resolve from config / admin settings, then the 600 s default.
[env: BIRDNET_STREAM_RETENTION_SECS=]
[default: 0]
--stream-max-mb <STREAM_MAX_MB>
Hard ceiling in mebibytes on the transient stream directory.
Backstop for many-stream or backed-up runs; the oldest segments drop first. `0` = resolve from config / admin settings, then the 512 MiB default.
[env: BIRDNET_STREAM_MAX_MB=]
[default: 0]
--raw-audio-keep-every <RAW_AUDIO_KEEP_EVERY>
Keep one raw capture segment in N on the data disk.
The raw audio is otherwise gone once analysed, so only what already triggered a detection survives and a season can never be re-analysed under a new model. With this set, one aged segment in N (in capture order) is copied to `<recordings>/raw` before the stream directory drains it; `1` keeps every segment. Budget it: a 15 s segment at 48 kHz mono is about 1.4 MB, so `1` is roughly 8 GB a day per source and `10` about 0.8 GB. The disk-full purge takes kept raw audio before any clip. Config file: `RAW_AUDIO_KEEP_EVERY`; unset or 0, nothing is kept.
[env: BIRDNET_RAW_AUDIO_KEEP_EVERY=]
--custom-image-dir <CUSTOM_IMAGE_DIR>
Directory containing custom species images (checked before Wikipedia cache).
Files should be named `{lowercase_sci_name_with_underscores}.jpg`, e.g. `turdus_merula.jpg`. There is no `BirdNET-Pi` equivalent: its `CUSTOM_IMAGE` is one picture for the Overview page, which is the `dashboard_image` setting here.
[env: BIRDNET_CUSTOM_IMAGE_DIR=]
--apprise-config <APPRISE_CONFIG>
Path to Apprise config file (alternative/addition to --apprise-url).
When set, uses the `apprise` CLI tool with `-c <file>` for notifications. `BirdNET-Pi` equivalent: its `apprise.txt`, which importing a BirdNET-Pi `birdnet.conf` brings across (Station, Data, or `--import-birdnet-pi-conf`).
[env: BIRDNET_APPRISE_CONFIG=]
--weekly-report-schedule <WEEKLY_REPORT_SCHEDULE>
Weekly report notification schedule.
Send a weekly summary via Apprise on a fixed weekday. Values: "monday", "tuesday", ..., "sunday", or "disabled". `BirdNET-Pi` equivalent: `weekly_report` cron job.
[env: BIRDNET_WEEKLY_REPORT_SCHEDULE=]
[default: monday]
--freq-shift-hz <FREQ_SHIFT_HZ>
Frequency shift applied to extracted audio clips in Hz (0 = disabled).
Shifts every frequency in a saved detection clip by the given number of Hz (a shift, not a ratio: an 8 kHz song at -3000 is heard at 5 kHz), and leaves the clip's length alone. Positive raises, negative lowers.
For age-related high-frequency hearing loss the useful direction is **negative**: it brings warbler and kinglet song, which lives above 8 kHz, down into a band that is still audible. Typical value: -3000. (Earlier releases documented this backwards — see `birdnet_core::audio::extraction::ACCESSIBILITY_SHIFT_HZ`.)
Requires ffmpeg 4.3 or newer (its `afreqshift` filter); without one the clip is saved unshifted and a warning says so. `BirdNET-Pi` equivalent: its `FREQSHIFT_*` options, which scale by a ratio instead.
[env: BIRDNET_FREQ_SHIFT_HZ=]
[default: 0]
--mqtt-host <MQTT_HOST>
MQTT broker hostname or IP address for `IoT` detection events.
When set, each bird detection is published as a JSON payload to the configured broker. Compatible with Home Assistant, Node-RED, Mosquitto, and any MQTT 3.1.1-compatible broker. Example: `--mqtt-host mqtt.local`
[env: BIRDNET_MQTT_HOST=]
--mqtt-port <MQTT_PORT>
MQTT broker port.
Left unset it is 1883, or 8883 when `--mqtt-tls` is given.
[env: BIRDNET_MQTT_PORT=]
--mqtt-tls
Connect to the MQTT broker over TLS (`mqtts`).
The certificate is verified against the platform trust store plus anything in `--mqtt-ca-file`. There is no way to skip verification: an unverified TLS connection carrying the broker credentials is worse than a plaintext one, because it looks safe.
[env: BIRDNET_MQTT_TLS=]
--mqtt-ca-file <MQTT_CA_FILE>
PEM file of extra certificates to trust for `--mqtt-tls`.
For a broker behind a private CA — or a self-signed one, whose own certificate works here as a trust anchor. Config key: `MQTT_CA_FILE`.
[env: BIRDNET_MQTT_CA_FILE=]
--mqtt-client-cert <MQTT_CLIENT_CERT>
PEM file with this station's client certificate, for a broker that requires mutual TLS. Needs `--mqtt-client-key`; implies `--mqtt-tls`. Config key: `MQTT_CLIENT_CERT`
[env: BIRDNET_MQTT_CLIENT_CERT=]
--mqtt-client-key <MQTT_CLIENT_KEY>
PEM file with the private key for `--mqtt-client-cert`. Config key: `MQTT_CLIENT_KEY`
[env: BIRDNET_MQTT_CLIENT_KEY=]
--mqtt-tls-server-name <MQTT_TLS_SERVER_NAME>
Hostname to validate the broker's certificate against.
Defaults to `--mqtt-host`. Set it when connecting to a broker by IP whose certificate names a hostname — the usual shape on a home LAN with no internal DNS. Config key: `MQTT_TLS_SERVER_NAME`.
[env: BIRDNET_MQTT_TLS_SERVER_NAME=]
--mqtt-client-id <MQTT_CLIENT_ID>
MQTT client identifier published in CONNECT packets.
Must be unique per active connection on the broker.
[env: BIRDNET_MQTT_CLIENT_ID=]
[default: birdnet-behavior]
--mqtt-username <MQTT_USERNAME>
MQTT broker username for authentication
[env: BIRDNET_MQTT_USERNAME=]
--mqtt-password <MQTT_PASSWORD>
MQTT broker password for authentication
[env: BIRDNET_MQTT_PASSWORD=]
--mqtt-topic-prefix <MQTT_TOPIC_PREFIX>
MQTT topic prefix for published messages (default: "birdnet").
Detections are published to `{prefix}/detection/{species}`. Status heartbeat is published to `{prefix}/status`.
[env: BIRDNET_MQTT_TOPIC_PREFIX=]
[default: birdnet]
--mqtt-retain
Set the RETAIN flag on MQTT detection messages.
When set, the broker stores the last detection per topic and delivers it immediately to new subscribers. Useful for Home Assistant sensors.
[env: BIRDNET_MQTT_RETAIN=]
--mqtt-ha-discovery
Publish Home Assistant MQTT auto-discovery messages at startup.
When enabled, BirdNet-Behavior publishes MQTT discovery payloads to `homeassistant/<component>/<uid>/config` so Home Assistant automatically creates sensors without manual `configuration.yaml` entries.
Requires `--mqtt-host` to be set. Harmless if HA is not running.
[env: BIRDNET_MQTT_HA_DISCOVERY=]
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
Keeping this page current: the
.env.exampleblock updates itself on every docs build. Regenerate the CLI block after changing any flag with:./scripts/gen-cli-help.shCI fails if the committed
cli-help.txtis stale, so this can’t silently drift.
Where every setting is changed
BirdNet-Behavior is meant to be set up and looked after from a browser: install it, open its address, and the setup wizard takes the station from first boot to listening. Everything a person would reasonably want to change later has a place under Settings. This page is the complete account of that claim, and a test (tests/every_setting_has_a_home_in_the_browser.rs) fails when it stops being true.
A setting is changed in the browser, or it is on this page with the reason it is not. The reasons are of three kinds:
- It could lock the owner out. The address the page is served on, HTTPS, and the files behind it. A wrong value leaves no page to correct it from.
- It needs something a browser cannot give the station. A certificate file, a model file of your own, a directory on the station’s disk.
- It must not live in the database. The offsite-backup passphrase and keys: every backup contains the settings table, so a secret saved there would sit inside the archive it protects.
How the settings take effect
Each setting on the Settings pages carries a small badge. Applies after restart means the station reads the value once, when it starts: save it, then press Restart the station now, which appears as soon as there is a saved change waiting (and after the setup wizard). Applies immediately and Applies within about 30 seconds mean the running station picks the value up by itself. The badge is not a guess: for each setting it comes from reading the code that consumes the value (the table below, and crates/birdnet-web/src/routes/admin/settings/applies.rs, name the evidence).
Restart the station works in three situations: under systemd (the service is restarted), and everywhere else the program shuts down cleanly and starts itself again, so a binary run by hand and a container without a restart policy are covered too.
Saved settings
Every key the settings pages and PUT /api/v2/settings write, where it is changed, and when a saved value reaches the thing it controls.
| Setting | Changed at | When it applies |
|---|---|---|
confidence_threshold | Settings → Capture → Detection Settings → Minimum Confidence (0–1) (confidence_threshold) | Applies after restart |
sensitivity | Settings → Capture → Detection Settings → Sensitivity (0.5–1.5) (sensitivity) | Applies after restart |
overlap | Settings → Capture → Detection Settings → Analysis Overlap (0–2.9 seconds) (overlap) | Applies after restart |
sf_thresh | Settings → Capture → Detection Settings → Species Frequency Threshold (0–1) (sf_thresh) | Applies after restart |
privacy_threshold | Settings → Capture → Detection Settings → Privacy Threshold (0 = disabled) (privacy_threshold) | Applies after restart |
privacy_rank | Settings → Capture → Detection Settings → Privacy Rank ({rank_min} = disabled, up to {rank_max}) (privacy_rank) | Applies after restart |
confirmation_level | Settings → Capture → Detection Settings → Repeat Confirmation (confirmation_level) | Applies after restart |
alsa_device | Settings → Capture → Audio Capture → Microphone address (alsa_device) | Applies after restart |
alsa_devices | Settings → Capture → Audio sources (several microphones are rows there; this key only seeds them at the first start) | Applies after restart |
rtsp_url | Settings → Capture → Audio Capture → Network camera address (rtsp_url) | Applies after restart |
rtsp_urls | Settings → Capture → Audio Capture → Several network cameras (separated by commas) (rtsp_urls) | Applies after restart |
audio_format | Settings → Capture → Audio Capture → Extracted Clip Format (audio_format) | Applies after restart |
segment_duration | Settings → Capture → Audio Capture → Segment Duration (seconds) (segment_duration) | Applies after restart |
freq_shift_hz | Settings → Capture → Audio Capture → Frequency Shift (Hz, 0 = disabled) (freq_shift_hz) | Applies after restart |
clip_target_lufs | Settings → Capture → Audio Capture → Even out clip volume (clip_target_lufs) | Applies after restart |
livestream_source | Recordings → Live (the source picker) | Applies immediately |
analytics_exclude_imports | Settings → Data → Bring your history with you | Applies immediately |
today_layout | Settings → General → Today Page Layout | Applies immediately |
latitude | Settings → Capture → Location & Recording Schedule → Latitude (latitude) | Applies after restart |
longitude | Settings → Capture → Location & Recording Schedule → Longitude (longitude) | Applies after restart |
station_name | Settings → Capture → Location & Recording Schedule → Station Name (station_name) | Applies after restart |
recording_schedule | Settings → Capture → Location & Recording Schedule → Recording window (recording_schedule) | Applies after restart |
timezone | Set by the setup wizard’s location lookup; the station’s clock zone is the operating system’s (see below) | Recorded only |
onboarding_complete | Set when the setup wizard finishes | Applies immediately |
night_inhibit | Settings → Capture → Location & Recording Schedule → Night Inhibit (suppress recording in darkness) (night_inhibit) | Applies after restart |
pre_sunrise_offset | Settings → Capture → Location & Recording Schedule → Extra minutes before sunrise (pre_sunrise_offset) | Applies after restart |
post_sunset_offset | Settings → Capture → Location & Recording Schedule → Extra minutes after sunset (post_sunset_offset) | Applies after restart |
site_name | Settings → General → System & Display → Site Name (site_name) | Applies after restart |
info_site | Settings → General → System & Display → Species Info Links (info_site) | Applies after restart |
apprise_url | Settings → Alerts → Notifications → Address of your Apprise server (apprise_url) | Applies after restart |
apprise_config | Settings → Alerts → Notifications → Path to an Apprise configuration file (apprise_config) | Applies after restart |
notify_urls | Settings → Alerts → Notifications → Where to send alerts (notify_urls) | Applies after restart |
birdweather_token | Settings → Alerts → Notifications → BirdWeather Station Token (birdweather_token) | Applies after restart |
notify_confidence | Settings → Alerts → Notifications → Notification Min Confidence (notify_confidence) | Applies after restart |
notify_cooldown | Settings → Alerts → Notifications → Notification Cooldown (seconds) (notify_cooldown) | Applies after restart |
notify_trigger | Settings → Alerts → Notifications → Notification Trigger (notify_trigger) | Applies after restart |
notify_species_only | Settings → Alerts → Notifications → Notify only for these species (common names, separated by commas) (notify_species_only) | Applies after restart |
notify_species_exclude | Settings → Alerts → Notifications → Never notify for these species (common names, separated by commas) (notify_species_exclude) | Applies after restart |
notify_title_template | Settings → Alerts → Notifications → Notification Title Template (notify_title_template) | Applies after restart |
notify_body_template | Settings → Alerts → Notifications → Notification Body Template (notify_body_template) | Applies after restart |
weekly_report_schedule | Settings → Alerts → Notifications → Weekly Report Day (weekly_report_schedule) | Applies after restart |
heartbeat_url | Settings → Alerts → Notifications → Heartbeat URL (heartbeat_url) | Applies after restart |
deadman_hours | Settings → Alerts → Notifications → Dead-man alert after (hours of no detections) (deadman_hours) | Applies after restart |
database_lang | Settings → General → System & Display → Common-name language (database_lang) | Applies after restart |
species_include | Settings → Capture → Which birds count → Record only these (species_include) | Applies within about 30 seconds |
species_exclude | Settings → Capture → Which birds count → Never record these (species_exclude) | Applies within about 30 seconds |
species_whitelist | Settings → Capture → Which birds count → Always allowed (species_whitelist) | Applies within about 30 seconds |
image_cache_dir | Settings → General → System & Display → Species Image Cache Directory (image_cache_dir) | Applies after restart |
custom_image_dir | Settings → General → System & Display → Custom Species Image Directory (custom_image_dir) | Applies after restart |
dashboard_image | Settings → General → System & Display → Today page picture (file) (dashboard_image) | Applies immediately |
dashboard_image_title | Settings → General → System & Display → Picture title (dashboard_image_title) | Applies immediately |
max_files_per_species | Settings → General → System & Display → Max Files Per Species (0 = unlimited) (max_files_per_species) | Applies after restart |
purge_threshold | Settings → General → System & Display → Disk Purge Threshold (%) (purge_threshold) | Applies after restart |
full_disk_action | Settings → General → System & Display → When the disk is full (full_disk_action) | Applies after restart |
extraction_length | Settings → General → System & Display → Clip Length (seconds) (extraction_length) | Applies after restart |
raw_spectrogram | Settings → General → System & Display → Bare spectrograms (raw_spectrogram) | Applies immediately |
spectrogram_colormap | Settings → General → System & Display → Spectrogram colours (spectrogram_colormap) | Applies immediately |
spectrogram_range_db | Settings → General → System & Display → Spectrogram dynamic range (spectrogram_range_db) | Applies immediately |
time_format | Settings → General → System & Display → Clock (time_format) | Applies immediately |
temperature_unit | Settings → General → System & Display → Temperature unit (temperature_unit) | Applies immediately |
rare_species_days | Settings → General → System & Display → Rare after (days) (rare_species_days) | Applies immediately |
stream_retention_secs | Settings → General → System & Display → Raw Segment Retention (seconds) (stream_retention_secs) | Applies after restart |
stream_max_mb | Settings → General → System & Display → Streaming Folder Limit (MB) (stream_max_mb) | Applies after restart |
clip_retention_days | Settings → General → System & Display → Keep Clip Audio (days, 0 = forever) (clip_retention_days) | Applies after restart |
email_smtp_host | Settings → Alerts → Email Alerts → Outgoing mail server (SMTP host) (email_smtp_host) | Applies after restart |
email_smtp_port | Settings → Alerts → Email Alerts → Port (email_smtp_port) | Applies after restart |
email_smtp_user | Settings → Alerts → Email Alerts → Your email address (email_smtp_user) | Applies after restart |
email_smtp_pass | Settings → Alerts → Email Alerts → App password (email_smtp_pass) | Applies after restart |
email_from | Settings → Alerts → Email Alerts → Send from (email_from) | Applies after restart |
email_to | Settings → Alerts → Email Alerts → Send to (email_to) | Applies after restart |
email_from_name | Settings → Alerts → Email Alerts → Sender name (email_from_name) | Applies after restart |
email_starttls | Settings → Alerts → Email Alerts → Encryption (email_starttls) | Applies after restart |
email_min_confidence | Settings → Alerts → Email Alerts → Only email when at least this sure (0–1) (email_min_confidence) | Applies after restart |
email_cooldown_secs | Settings → Alerts → Email Alerts → Wait this long before emailing about the same bird again (seconds) (email_cooldown_secs) | Applies after restart |
mqtt_host | Settings → Alerts → Home Assistant & MQTT → Broker address (mqtt_host) | Applies after restart |
mqtt_port | Settings → Alerts → Home Assistant & MQTT → Broker port (mqtt_port) | Applies after restart |
mqtt_username | Settings → Alerts → Home Assistant & MQTT → Broker user name (mqtt_username) | Applies after restart |
mqtt_password | Settings → Alerts → Home Assistant & MQTT → Broker password (mqtt_password) | Applies after restart |
mqtt_tls | Settings → Alerts → Home Assistant & MQTT → Secure connection (TLS) (mqtt_tls) | Applies after restart |
mqtt_ca_file | Settings → Alerts → Home Assistant & MQTT → Broker trust file (PEM) (mqtt_ca_file) | Applies after restart |
mqtt_client_cert | Settings → Alerts → Home Assistant & MQTT → Client certificate (PEM) (mqtt_client_cert) | Applies after restart |
mqtt_client_key | Settings → Alerts → Home Assistant & MQTT → Client private key (PEM) (mqtt_client_key) | Applies after restart |
mqtt_topic_prefix | Settings → Alerts → Home Assistant & MQTT → Topic prefix (mqtt_topic_prefix) | Applies after restart |
mqtt_retain | Settings → Alerts → Home Assistant & MQTT → Keep the last message at the broker (mqtt_retain) | Applies after restart |
mqtt_ha_discovery | Settings → Alerts → Home Assistant & MQTT → Tell Home Assistant about this station (mqtt_ha_discovery) | Applies after restart |
notify_rate_per_minute | Settings → Alerts → Alert limits → At most this many alerts per minute (notify_rate_per_minute) | Applies after restart |
station_health_alerts | Settings → Alerts → Alert limits → Tell me when the station has a problem (station_health_alerts) | Applies after restart |
duplicate_interval_secs | Settings → Capture → Detection filters → Ignore repeats of the same bird for (seconds) (duplicate_interval_secs) | Applies after restart |
noise_threshold | Settings → Capture → Detection filters → Drop recordings with a barking dog above (0 to 1) (noise_threshold) | Applies after restart |
noise_classes | Settings → Capture → Detection filters → Noises to watch for (noise_classes) | Applies after restart |
noise_remember_secs | Settings → Capture → Detection filters → Keep dropping recordings for this long after a noise (seconds) (noise_remember_secs) | Applies after restart |
night_filter | Settings → Capture → Detection filters → Hold back day birds heard at night (night_filter) | Applies after restart |
night_margin_mins | Settings → Capture → Detection filters → Minutes of dusk and dawn that still count as day (night_margin_mins) | Applies after restart |
night_extra_nocturnal | Settings → Capture → Detection filters → Extra birds allowed at night (night_extra_nocturnal) | Applies after restart |
pre_capture_secs | Settings → Capture → Detection filters → Extra seconds of sound before each call in saved clips (pre_capture_secs) | Applies after restart |
backup_schedule | Settings → Data → Backups → Back up the database (backup_schedule) | Applies after restart |
purge_species_floor | Settings → General → Storage → Clips of every species that are never purged (purge_species_floor) | Applies after restart |
raw_audio_keep_every | Settings → General → Storage → Keep one raw recording in every (raw_audio_keep_every) | Applies after restart |
ebird_api_key | Settings → General → Online services → eBird key (ebird_api_key) | Applies after restart |
ebird_region | Settings → General → Online services → eBird region (ebird_region) | Applies after restart |
ebird_dist_km | Settings → General → Online services → Radius around the station (km) (ebird_dist_km) | Applies after restart |
ebird_back_days | Settings → General → Online services → Look back this many days (ebird_back_days) | Applies after restart |
image_provider | Settings → General → Online services → Where species photos come from (image_provider) | Applies after restart |
flickr_api_key | Settings → General → Online services → Flickr key (flickr_api_key) | Applies after restart |
flickr_filter_email | Settings → General → Online services → Only photos by this Flickr account (email) (flickr_filter_email) | Applies after restart |
birdweather_threshold | Settings → General → Online services → BirdWeather upload threshold (0 to 1) (birdweather_threshold) | Applies after restart |
birdweather_location_accuracy | Settings → General → Online services → Blur the BirdWeather position by up to (metres) (birdweather_location_accuracy) | Applies after restart |
embed_origins | Settings → General → Online services → Sites that may show this station in a frame (embed_origins) | Applies after restart |
Configuration-file keys
Every key birdnet.conf can carry (KNOWN_CONFIG_KEYS). A settings key bridged onto a configuration key shows where it is edited; the rest say why not.
| Key | Changed in the browser at | If not, why |
|---|---|---|
ALSA_CARD | Settings → Capture → Audio Capture → Microphone address (alsa_device) | |
ALSA_CARDS | Settings → Capture → Audio sources (several microphones are rows there; this key only seeds them at the first start) | |
ANALYTICS_DB_PATH | — | Where the station keeps its data. Moving it means moving the files first, which a page cannot do; pointing the station at a new path from a form would start it on an empty database and look like lost history. |
APPRISE_BODY_TEMPLATE | Settings → Alerts → Notifications → Notification Body Template (notify_body_template) | |
APPRISE_CONFIG_FILE | Settings → Alerts → Notifications → Path to an Apprise configuration file (apprise_config) | |
APPRISE_COOLDOWN | Settings → Alerts → Notifications → Notification Cooldown (seconds) (notify_cooldown) | |
APPRISE_MIN_CONFIDENCE | Settings → Alerts → Notifications → Notification Min Confidence (notify_confidence) | |
APPRISE_TITLE_TEMPLATE | Settings → Alerts → Notifications → Notification Title Template (notify_title_template) | |
APPRISE_TRIGGER | Settings → Alerts → Notifications → Notification Trigger (notify_trigger) | |
APPRISE_URL | Settings → Alerts → Notifications → Address of your Apprise server (apprise_url) | |
APPRISE_WATCHLIST | Settings → Alerts → Notifications → Notify only for these species (common names, separated by commas) (notify_species_only) | |
APPRISE_WATCHLIST_EXCLUDE | Settings → Alerts → Notifications → Never notify for these species (common names, separated by commas) (notify_species_exclude) | |
AUDIOFMT | Settings → Capture → Audio Capture → Extracted Clip Format (audio_format) | |
AUDIO_FORMAT | Settings → Capture → Audio Capture → Extracted Clip Format (audio_format) (the same setting under its other spelling) | |
BACKUP_SCHEDULE | Settings → Data → Backups → Back up the database (backup_schedule) | |
BIRDNET_DYNAMIC_THRESHOLD | — | Dynamic thresholds are being given their own page and list/reset controls (gap P-11); this setting will live there, not on the settings form. |
BIRDNET_DYNAMIC_THRESHOLD_HOURS | — | Part of the dynamic-threshold feature; see BIRDNET_DYNAMIC_THRESHOLD. |
BIRDNET_DYNAMIC_THRESHOLD_MIN | — | Part of the dynamic-threshold feature; see BIRDNET_DYNAMIC_THRESHOLD. |
BIRDNET_DYNAMIC_THRESHOLD_TRIGGER | — | Part of the dynamic-threshold feature; see BIRDNET_DYNAMIC_THRESHOLD. |
BIRDWEATHER_LOCATION_ACCURACY | Settings → General → Online services → Blur the BirdWeather position by up to (metres) (birdweather_location_accuracy) | |
BIRDWEATHER_THRESHOLD | Settings → General → Online services → BirdWeather upload threshold (0 to 1) (birdweather_threshold) | |
BIRDWEATHER_TOKEN | Settings → Alerts → Notifications → BirdWeather Station Token (birdweather_token) | |
BIRDWEATHER_URL | — | Points uploads at a different BirdWeather server; used only to test against a stand-in. A real station never needs it. |
BNB_API_TOKEN | — | A credential for programs (scripts, Home Assistant) to use the write API. Showing or generating one in a page would put a long-lived secret in browser history and screenshots; set it with a secret file or the environment. |
CADDY_PWD | The setup wizard’s first step, and Settings → Access → Accounts | |
CLIP_PEAK_CEILING_DBFS | — | The ceiling the clip-loudness evening-out will not push a clip past (default -1 dBFS). The “Even out clip volume” choice picks a sensible one; the ceiling itself is for audio engineers. |
CLIP_RETENTION_DAYS | Settings → General → System & Display → Keep Clip Audio (days, 0 = forever) (clip_retention_days) | |
CLIP_TARGET_LUFS | Settings → Capture → Audio Capture → Even out clip volume (clip_target_lufs) | |
CONFIDENCE | Settings → Capture → Detection Settings → Minimum Confidence (0–1) (confidence_threshold) | |
CONFIRMATION_LEVEL | Settings → Capture → Detection Settings → Repeat Confirmation (confirmation_level) | |
CUSTOM_IMAGE_DIR | Settings → General → System & Display → Custom Species Image Directory (custom_image_dir) | |
DATABASE_LANG | Settings → General → System & Display → Common-name language (database_lang) | |
DB_PATH | — | Where the station keeps its data. Moving it means moving the files first, which a page cannot do; pointing the station at a new path from a form would start it on an empty database and look like lost history. |
DEADMAN_HOURS | Settings → Alerts → Notifications → Dead-man alert after (hours of no detections) (deadman_hours) | |
DISK_PURGE_THRESHOLD | Settings → General → System & Display → Disk Purge Threshold (%) (purge_threshold) | |
DUPLICATE_INTERVAL_SECS | Settings → Capture → Detection filters → Ignore repeats of the same bird for (seconds) (duplicate_interval_secs) | |
EBIRD_API_KEY | Settings → General → Online services → eBird key (ebird_api_key) | |
EBIRD_BACK_DAYS | Settings → General → Online services → Look back this many days (ebird_back_days) | |
EBIRD_DIST_KM | Settings → General → Online services → Radius around the station (km) (ebird_dist_km) | |
EBIRD_REGION | Settings → General → Online services → eBird region (ebird_region) | |
EMBED_ORIGINS | Settings → General → Online services → Sites that may show this station in a frame (embed_origins) | |
EXTRACTION_LENGTH | Settings → General → System & Display → Clip Length (seconds) (extraction_length) | |
FLICKR_API_KEY | Settings → General → Online services → Flickr key (flickr_api_key) | |
FLICKR_FILTER_EMAIL | Settings → General → Online services → Only photos by this Flickr account (email) (flickr_filter_email) | |
FREQ_SHIFT | Settings → Capture → Audio Capture → Frequency Shift (Hz, 0 = disabled) (freq_shift_hz) | |
FULL_DISK | Settings → General → System & Display → When the disk is full (full_disk_action) | |
HEARTBEAT_URL | Settings → Alerts → Notifications → Heartbeat URL (heartbeat_url) | |
IMAGE_CACHE_DIR | Settings → General → System & Display → Species Image Cache Directory (image_cache_dir) | |
IMAGE_PROVIDER | Settings → General → Online services → Where species photos come from (image_provider) | |
INFO_SITE | Settings → General → System & Display → Species Info Links (info_site) | |
LABELS_DIR | — | A directory of species-name files that replace the built-in names. Needs files placed on the station’s disk, which a browser cannot do; 35 languages are built in and chosen under Common-name language. |
LABELS_PATH | — | Set for you by the installer or by the Bird-identifying model panel. A different labels file than the model’s own reports species under other species’ names, so it is only ever set together with its model, in the file. |
LANG | Settings → General → System & Display → Common-name language (database_lang); the same setting under its other spelling | |
LATITUDE | Settings → Capture → Location & Recording Schedule → Latitude (latitude) | |
LOG_LEVEL | — | Read at the very first moment of start-up, before the database is open, so a value saved in a page could not be seen in time. The live log is the System Logs page (Settings → Health → Overview). |
LOG_MODULES | — | Read at the very first moment of start-up, before the database is open (see LOG_LEVEL). |
LONGITUDE | Settings → Capture → Location & Recording Schedule → Longitude (longitude) | |
MAX_FILES_SPECIES | Settings → General → System & Display → Max Files Per Species (0 = unlimited) (max_files_per_species) | |
METADATA_LABELS_PATH | — | Only needed for the BirdNET+ V3.0 geomodel, whose species list differs from the classifier’s; set by the installer. Not used with the default classifier. |
METADATA_MODEL_PATH | — | Installed and switched on by the Bird-identifying model panel together with the classifier it belongs to (the panel shows whether the local species filter is on). A filter for a different classifier mislabels species, so choosing another is done in the file. |
MODEL | — | Names the primary classifier’s id for the routing and statistics pages; part of the multi-classifier setup, which is file-only (see MODEL_2_PATH). |
MODEL_2_ID | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_2_LABELS | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_2_PATH | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_2_SAMPLE_RATE | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_2_THRESHOLD | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_3_ID | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_3_LABELS | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_3_PATH | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_3_SAMPLE_RATE | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_3_THRESHOLD | — | A second or third classifier running beside the first. Each needs its own model and labels file on disk, a sample rate the model’s shape cannot state, and enough memory (the station refuses a model that would not fit); a wrong value silently mislabels species. --install-model list prints the exact lines. |
MODEL_DIR | — | Where the model panel installs and the station looks. The default (beside the database) is what almost everyone wants. |
MODEL_ID | — | Part of the multi-classifier set-up; see MODEL_2_PATH. |
MODEL_PATH | — | Set for you by the installer or by the Bird-identifying model panel (Settings → Capture). Pointing at a model file of your own is the advanced case and is done in the file, together with its labels. |
MODEL_ROUTES | — | Part of the multi-classifier set-up; see MODEL_2_PATH. |
MODEL_SAMPLE_RATE | — | Part of the multi-classifier set-up; see MODEL_2_PATH. |
MODEL_THRESHOLD | — | Part of the multi-classifier set-up; see MODEL_2_PATH. |
MQTT_CA_FILE | Settings → Alerts → Home Assistant & MQTT → Broker trust file (PEM) (mqtt_ca_file) | |
MQTT_CLIENT_CERT | Settings → Alerts → Home Assistant & MQTT → Client certificate (PEM) (mqtt_client_cert) | |
MQTT_CLIENT_KEY | Settings → Alerts → Home Assistant & MQTT → Client private key (PEM) (mqtt_client_key) | |
MQTT_HA_DISCOVERY | Settings → Alerts → Home Assistant & MQTT → Tell Home Assistant about this station (mqtt_ha_discovery) | |
MQTT_HOST | Settings → Alerts → Home Assistant & MQTT → Broker address (mqtt_host) | |
MQTT_PASSWORD | Settings → Alerts → Home Assistant & MQTT → Broker password (mqtt_password) | |
MQTT_PORT | Settings → Alerts → Home Assistant & MQTT → Broker port (mqtt_port) | |
MQTT_RETAIN | Settings → Alerts → Home Assistant & MQTT → Keep the last message at the broker (mqtt_retain) | |
MQTT_TLS | Settings → Alerts → Home Assistant & MQTT → Secure connection (TLS) (mqtt_tls) | |
MQTT_TLS_SERVER_NAME | — | Only needed when the broker’s certificate names a different host than the one connected to; a mismatch the browser could only reproduce as a failed connection; set in the file beside the broker trust file (Settings → Alerts → Home Assistant & MQTT). |
MQTT_TOPIC_PREFIX | Settings → Alerts → Home Assistant & MQTT → Topic prefix (mqtt_topic_prefix) | |
MQTT_USERNAME | Settings → Alerts → Home Assistant & MQTT → Broker user name (mqtt_username) | |
NIGHT_EXTRA_NOCTURNAL | Settings → Capture → Detection filters → Extra birds allowed at night (night_extra_nocturnal) | |
NIGHT_FILTER | Settings → Capture → Detection filters → Hold back day birds heard at night (night_filter) | |
NIGHT_INHIBIT | Settings → Capture → Location & Recording Schedule → Night Inhibit (suppress recording in darkness) (night_inhibit) | |
NIGHT_MARGIN_MINS | Settings → Capture → Detection filters → Minutes of dusk and dawn that still count as day (night_margin_mins) | |
NOISE_CLASSES | Settings → Capture → Detection filters → Noises to watch for (noise_classes) | |
NOISE_REMEMBER_SECS | Settings → Capture → Detection filters → Keep dropping recordings for this long after a noise (seconds) (noise_remember_secs) | |
NOISE_THRESHOLD | Settings → Capture → Detection filters → Drop recordings with a barking dog above (0 to 1) (noise_threshold) | |
NOTIFY_RATE_PER_MINUTE | Settings → Alerts → Alert limits → At most this many alerts per minute (notify_rate_per_minute) | |
NOTIFY_URLS | Settings → Alerts → Notifications → Where to send alerts (notify_urls) | |
OFFSITE_BACKUP | — | Offsite backups are encrypted with a passphrase and need credentials. The settings table is inside every backup, so a passphrase or secret key saved there would sit inside the archive it protects; they are read from the environment or the config file only, on purpose (src/helpers/offsite.rs). |
OFFSITE_KEEP | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP for why it is file-only. |
OFFSITE_PASSPHRASE | — | A secret that must never be stored in the database being backed up; see OFFSITE_BACKUP. |
OFFSITE_RSYNC_BWLIMIT | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_S3_ACCESS_KEY | — | Credential for the offsite bucket; see OFFSITE_BACKUP. |
OFFSITE_S3_ADDRESSING | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_S3_BUCKET | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_S3_ENDPOINT | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_S3_PREFIX | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_S3_REGION | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_S3_SECRET_KEY | — | A secret that must never be stored in the database being backed up; see OFFSITE_BACKUP. |
OFFSITE_SFTP_DIR | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_SFTP_HOST | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_SFTP_HOST_KEY_POLICY | — | A security choice about trusting the remote machine; set once in the file, see OFFSITE_BACKUP. |
OFFSITE_SFTP_IDENTITY | — | Path of an SSH private key on the station’s disk; see OFFSITE_BACKUP. |
OFFSITE_SFTP_KNOWN_HOSTS | — | Path of a file on the station’s disk; see OFFSITE_BACKUP. |
OFFSITE_SFTP_PORT | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OFFSITE_SFTP_USER | — | Part of the offsite-backup set-up; see OFFSITE_BACKUP. |
OVERLAP | Settings → Capture → Detection Settings → Analysis Overlap (0–2.9 seconds) (overlap) | |
PIPEWIRE_DEVICE | Settings → Capture → Audio sources → Add microphone → PipeWire microphone | |
POST_SUNSET_OFFSET | Settings → Capture → Location & Recording Schedule → Extra minutes after sunset (post_sunset_offset) | |
PRE_CAPTURE_SECS | Settings → Capture → Detection filters → Extra seconds of sound before each call in saved clips (pre_capture_secs) | |
PRE_SUNRISE_OFFSET | Settings → Capture → Location & Recording Schedule → Extra minutes before sunrise (pre_sunrise_offset) | |
PRIVACY_RANK | Settings → Capture → Detection Settings → Privacy Rank ({rank_min} = disabled, up to {rank_max}) (privacy_rank) | |
PRIVACY_THRESHOLD | Settings → Capture → Detection Settings → Privacy Threshold (0 = disabled) (privacy_threshold) | |
PRIVATE_MODE | — | Putting the whole station behind the sign-in changes who can reach the page. Switched on with no admin password set, every visitor, including the owner, is answered 503 and nobody can sign in; switched on from a page the owner is looking at through a proxy that is not signed in, the page is gone mid-click. It is chosen in the file with the service stopped (hardening guide, section 5). |
PUBLIC_ACCESS | — | The list of surfaces that stay open on a private station; it only means anything with PRIVATE_MODE, and has the same lock-out risk. |
PURGE_SPECIES_FLOOR | Settings → General → Storage → Clips of every species that are never purged (purge_species_floor) | |
RAW_AUDIO_KEEP_EVERY | Settings → General → Storage → Keep one raw recording in every (raw_audio_keep_every) | |
RECORDING_LENGTH | Settings → Capture → Audio Capture → Segment Duration (segment_duration); read as SEGMENT_DURATION when that is not set | |
RECORDING_SCHEDULE | Settings → Capture → Location & Recording Schedule → Recording window (recording_schedule) | |
RECS_DIR | — | Where clips are kept (and, when no watch directory is given, where capture segments land). Moving it means moving the files first; a path typed in a form would leave the existing clips behind. |
RTSP_URL | Settings → Capture → Audio Capture → Network camera address (rtsp_url) | |
RTSP_URLS | Settings → Capture → Audio Capture → Several network cameras (separated by commas) (rtsp_urls) | |
SEGMENT_DURATION | Settings → Capture → Audio Capture → Segment Duration (seconds) (segment_duration) | |
SENSITIVITY | Settings → Capture → Detection Settings → Sensitivity (0.5–1.5) (sensitivity) | |
SF_THRESH | Settings → Capture → Detection Settings → Species Frequency Threshold (0–1) (sf_thresh) | |
SITENAME | Settings → General → System & Display → Site Name (site_name) | |
SPECIES_ALIASES_PATH | — | A tab-separated file of legacy names kept by hand over months; it needs a file on the station’s disk. |
STATION_HEALTH_ALERTS | Settings → Alerts → Alert limits → Tell me when the station has a problem (station_health_alerts) | |
STATION_NAME | Settings → Capture → Location & Recording Schedule → Station Name (station_name) | |
STREAM_MAX_MB | Settings → General → System & Display → Streaming Folder Limit (MB) (stream_max_mb) | |
STREAM_RETENTION_SECS | Settings → General → System & Display → Raw Segment Retention (seconds) (stream_retention_secs) | |
TLS_CERT | — | Changing how the page is served (HTTPS, certificates, the address it listens on) from the page itself can lock the owner out: a wrong certificate or address makes the page unreachable, with no page left to undo it. Set in the file with the service stopped. |
TLS_DIR | — | Same reason as TLS_CERT: part of the HTTPS set-up, which can lock the owner out if changed from the page. |
TLS_KEY | — | Same reason as TLS_CERT: a wrong key makes the station unreachable and there is no page left to fix it from. |
WATCHDOG_BACKOFF_BASE_SECS | — | Timings of the capture watchdog (how fast a dead microphone is restarted). The defaults suit every station; changing them is a support exercise, and the log says when one is adjusted. |
WATCHDOG_BACKOFF_CAP_SECS | — | Capture watchdog timing; see WATCHDOG_BACKOFF_BASE_SECS. |
WATCHDOG_CHECK_SECS | — | Capture watchdog timing; see WATCHDOG_BACKOFF_BASE_SECS. |
WATCHDOG_DOWN_WARN_AFTER_SECS | — | Capture watchdog timing; see WATCHDOG_BACKOFF_BASE_SECS. |
WATCHDOG_DOWN_WARN_EVERY_SECS | — | Capture watchdog timing; see WATCHDOG_BACKOFF_BASE_SECS. |
WATCHDOG_STALL_FLOOR_SECS | — | Capture watchdog timing; see WATCHDOG_BACKOFF_BASE_SECS. |
WATCHDOG_STALL_SEGMENTS | — | Capture watchdog timing; see WATCHDOG_BACKOFF_BASE_SECS. |
WEATHER_API_KEY | — | Part of the weather overlay, which is opt-in by environment variable only; see WEATHER_PROVIDER. |
WEATHER_PROVIDER | — | The weather overlay is switched on by the environment variable BNB_WEATHER_ENABLED=1, deliberately not a setting, so a station never contacts a weather service unless the person who runs it set that up where the program starts. |
WEATHER_STATION_ID | — | Part of the weather overlay, which is opt-in by environment variable only; see WEATHER_PROVIDER. |
WEEKLY_REPORT_SCHEDULE | Settings → Alerts → Notifications → Weekly Report Day (weekly_report_schedule) |
Command-line flags
Every flag birdnet-behavior --help lists, the environment variable that sets it, and where the same thing is changed in the browser. Flags and environment variables set a value at launch and win over the Settings pages; a setting that is made that way is not shown on its page.
| Flag | Environment variable | Changed in the browser at | If not, why |
|---|---|---|---|
--alsa-device | BIRDNET_ALSA_DEVICE | Settings → Capture → Audio Capture → Microphone address (alsa_device) (the same as ALSA_CARD) | |
--alsa-devices | BIRDNET_ALSA_DEVICES | Settings → Capture → Audio sources (several microphones are rows there; this key only seeds them at the first start) (the same as ALSA_CARDS) | |
--analytics-db | BIRDNET_ANALYTICS_DB | — | Same as ANALYTICS_DB_PATH: Where the station keeps its data. Moving it means moving the files first, which a page cannot do; pointing the station at a new path from a form would start it on an empty database and look like lost history. |
--apply-config | — | — | Installs a replacement configuration file and restarts the service; a file-editing workflow. The Settings pages are its browser equivalent. |
--apply-import | — | Settings → Data → Bring your history with you → Apply | |
--apprise-config | BIRDNET_APPRISE_CONFIG | Settings → Alerts → Notifications → Path to an Apprise configuration file (apprise_config) (the same as APPRISE_CONFIG_FILE) | |
--apprise-url | BIRDNET_APPRISE_URL | Settings → Alerts → Notifications → Address of your Apprise server (apprise_url) (the same as APPRISE_URL) | |
--audio-format | BIRDNET_AUDIO_FORMAT | Settings → Capture → Audio Capture → Extracted Clip Format (audio_format) (the same as AUDIOFMT) | |
--backup-db | — | Settings → Data → Backups → Back up now | |
--birdweather-token | BIRDNET_BIRDWEATHER_TOKEN | Settings → Alerts → Notifications → BirdWeather Station Token (birdweather_token) (the same as BIRDWEATHER_TOKEN) | |
--channel-report | — | — | Records from the microphone exclusively for a few seconds to report what a stereo microphone delivers; the running station owns the microphone, so this is run with it stopped. |
--channel-report-secs | — | — | Only meaningful with –channel-report; see that flag. |
--check-db | — | — | A run-and-exit maintenance command. The station runs the same check on a schedule, and the station-health alerts say when it has not completed in weeks. |
--clip-retention-days | BIRDNET_CLIP_RETENTION_DAYS | Settings → General → System & Display → Keep Clip Audio (days, 0 = forever) (clip_retention_days) (the same as CLIP_RETENTION_DAYS) | |
--clips-mode | — | Settings → Data → Bring your history with you → Recordings → Copy, link, move or leave | |
--config | BIRDNET_CONFIG | — | Names the configuration file the program reads at the very start, before there is a database or a page to show settings from. |
--confirmation-level | BIRDNET_CONFIRMATION_LEVEL | Settings → Capture → Detection Settings → Repeat Confirmation (confirmation_level) (the same as CONFIRMATION_LEVEL) | |
--custom-image-dir | BIRDNET_CUSTOM_IMAGE_DIR | Settings → General → System & Display → Custom Species Image Directory (custom_image_dir) (the same as CUSTOM_IMAGE_DIR) | |
--deadman-hours | BIRDNET_DEADMAN_HOURS | Settings → Alerts → Notifications → Dead-man alert after (hours of no detections) (deadman_hours) (the same as DEADMAN_HOURS) | |
--decrypt-backup | — | — | An offline recovery tool for when the station itself may be gone; it has to run without a working station. |
--disk-exclude | BIRDNET_DISK_EXCLUDE | — | Paths on the station’s disk to leave out of disk monitoring; a list of directories only someone with a shell knows. |
--disk-purge-threshold | BIRDNET_DISK_PURGE_THRESHOLD | Settings → General → System & Display → Disk Purge Threshold (%) (purge_threshold) (the same as DISK_PURGE_THRESHOLD) | |
--doctor | — | Settings → Health → Diagnostics | |
--doctor-gate | — | — | The service’s start gate, run by systemd before the program starts, so it cannot be a page. |
--doctor-json | — | Settings → Health → Diagnostics → Download as JSON | |
--duplicate-interval-secs | BIRDNET_DUPLICATE_INTERVAL_SECS | Settings → Capture → Detection filters → Ignore repeats of the same bird for (seconds) (duplicate_interval_secs) (the same as DUPLICATE_INTERVAL_SECS) | |
--extraction-length | BIRDNET_EXTRACTION_LENGTH | Settings → General → System & Display → Clip Length (seconds) (extraction_length) (the same as EXTRACTION_LENGTH) | |
--fix | — | — | Safe repairs (missing directories) run with the diagnostic at the command line; the station creates its directories at start and Diagnostics reports any it cannot. |
--freq-shift-hz | BIRDNET_FREQ_SHIFT_HZ | Settings → Capture → Audio Capture → Frequency Shift (Hz, 0 = disabled) (freq_shift_hz) (the same as FREQ_SHIFT) | |
--heartbeat-url | BIRDNET_HEARTBEAT_URL | Settings → Alerts → Notifications → Heartbeat URL (heartbeat_url) (the same as HEARTBEAT_URL) | |
--image-cache-dir | BIRDNET_IMAGE_CACHE_DIR | Settings → General → System & Display → Species Image Cache Directory (image_cache_dir) (the same as IMAGE_CACHE_DIR) | |
--import-birdnet-pi-clips | — | Settings → Data → Bring your history with you → Recordings | |
--import-birdnet-pi-conf | — | Settings → Data → Bring your history with you (upload birdnet.conf; the page shows every line before anything changes) | |
--info-site | BIRDNET_INFO_SITE | Settings → General → System & Display → Species Info Links (info_site) (the same as INFO_SITE) | |
--install-model | — | Settings → Capture → Bird-identifying model → Install | |
--labels | BIRDNET_LABELS | — | Same as LABELS_PATH: Set for you by the installer or by the Bird-identifying model panel. A different labels file than the model’s own reports species under other species’ names, so it is only ever set together with its model, in the file. |
--labels-dir | BIRDNET_LABELS_DIR | — | Same as LABELS_DIR: A directory of species-name files that replace the built-in names. Needs files placed on the station’s disk, which a browser cannot do; 35 languages are built in and chosen under Common-name language. |
--lang | BIRDNET_LANG | Settings → General → System & Display → Common-name language (database_lang) (the same as DATABASE_LANG) | |
--latitude | BIRDNET_LATITUDE | Settings → Capture → Location & Recording Schedule → Latitude (latitude) (the same as LATITUDE) | |
--listen | BIRDNET_LISTEN | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--log-level | BIRDNET_LOG_LEVEL | — | Same as LOG_LEVEL: Read at the very first moment of start-up, before the database is open, so a value saved in a page could not be seen in time. The live log is the System Logs page (Settings → Health → Overview). |
--log-modules | BIRDNET_LOG_MODULES | — | Same as LOG_MODULES: Read at the very first moment of start-up, before the database is open (see LOG_LEVEL). |
--longitude | BIRDNET_LONGITUDE | Settings → Capture → Location & Recording Schedule → Longitude (longitude) (the same as LONGITUDE) | |
--max-files-per-species | BIRDNET_MAX_FILES_PER_SPECIES | Settings → General → System & Display → Max Files Per Species (0 = unlimited) (max_files_per_species) (the same as MAX_FILES_SPECIES) | |
--metadata-labels | BIRDNET_METADATA_LABELS | — | Same as METADATA_LABELS_PATH: Only needed for the BirdNET+ V3.0 geomodel, whose species list differs from the classifier’s; set by the installer. Not used with the default classifier. |
--metadata-model | BIRDNET_METADATA_MODEL | — | Same as METADATA_MODEL_PATH: Installed and switched on by the Bird-identifying model panel together with the classifier it belongs to (the panel shows whether the local species filter is on). A filter for a different classifier mislabels species, so choosing another is done in the file. |
--migration-report | — | — | A dry run of the database upgrade that happens at the next start, for deciding whether to upgrade; it runs before the new version is running. |
--model | BIRDNET_MODEL | — | Same as MODEL_PATH: Set for you by the installer or by the Bird-identifying model panel (Settings → Capture). Pointing at a model file of your own is the advanced case and is done in the file, together with its labels. |
--mqtt-ca-file | BIRDNET_MQTT_CA_FILE | Settings → Alerts → Home Assistant & MQTT → Broker trust file (PEM) (mqtt_ca_file) (the same as MQTT_CA_FILE) | |
--mqtt-client-cert | BIRDNET_MQTT_CLIENT_CERT | Settings → Alerts → Home Assistant & MQTT → Client certificate (PEM) (mqtt_client_cert) (the same as MQTT_CLIENT_CERT) | |
--mqtt-client-id | BIRDNET_MQTT_CLIENT_ID | — | The identifier the station presents to the broker. Almost nobody changes it; two stations on one broker are told apart by topic prefix, which is a setting. |
--mqtt-client-key | BIRDNET_MQTT_CLIENT_KEY | Settings → Alerts → Home Assistant & MQTT → Client private key (PEM) (mqtt_client_key) (the same as MQTT_CLIENT_KEY) | |
--mqtt-ha-discovery | BIRDNET_MQTT_HA_DISCOVERY | Settings → Alerts → Home Assistant & MQTT → Tell Home Assistant about this station (mqtt_ha_discovery) (the same as MQTT_HA_DISCOVERY) | |
--mqtt-host | BIRDNET_MQTT_HOST | Settings → Alerts → Home Assistant & MQTT → Broker address (mqtt_host) (the same as MQTT_HOST) | |
--mqtt-password | BIRDNET_MQTT_PASSWORD | Settings → Alerts → Home Assistant & MQTT → Broker password (mqtt_password) (the same as MQTT_PASSWORD) | |
--mqtt-port | BIRDNET_MQTT_PORT | Settings → Alerts → Home Assistant & MQTT → Broker port (mqtt_port) (the same as MQTT_PORT) | |
--mqtt-retain | BIRDNET_MQTT_RETAIN | Settings → Alerts → Home Assistant & MQTT → Keep the last message at the broker (mqtt_retain) (the same as MQTT_RETAIN) | |
--mqtt-tls | BIRDNET_MQTT_TLS | Settings → Alerts → Home Assistant & MQTT → Secure connection (TLS) (mqtt_tls) (the same as MQTT_TLS) | |
--mqtt-tls-server-name | BIRDNET_MQTT_TLS_SERVER_NAME | — | Same as MQTT_TLS_SERVER_NAME: Only needed when the broker’s certificate names a different host than the one connected to; a mismatch the browser could only reproduce as a failed connection; set in the file beside the broker trust file (Settings → Alerts → Home Assistant & MQTT). |
--mqtt-topic-prefix | BIRDNET_MQTT_TOPIC_PREFIX | Settings → Alerts → Home Assistant & MQTT → Topic prefix (mqtt_topic_prefix) (the same as MQTT_TOPIC_PREFIX) | |
--mqtt-username | BIRDNET_MQTT_USERNAME | Settings → Alerts → Home Assistant & MQTT → Broker user name (mqtt_username) (the same as MQTT_USERNAME) | |
--night-extra-nocturnal | BIRDNET_NIGHT_EXTRA_NOCTURNAL | Settings → Capture → Detection filters → Extra birds allowed at night (night_extra_nocturnal) (the same as NIGHT_EXTRA_NOCTURNAL) | |
--night-filter | BIRDNET_NIGHT_FILTER | Settings → Capture → Detection filters → Hold back day birds heard at night (night_filter) (the same as NIGHT_FILTER) | |
--night-inhibit | BIRDNET_NIGHT_INHIBIT | Settings → Capture → Location & Recording Schedule → Night Inhibit (suppress recording in darkness) (night_inhibit) (the same as NIGHT_INHIBIT) | |
--night-margin-mins | BIRDNET_NIGHT_MARGIN_MINS | Settings → Capture → Detection filters → Minutes of dusk and dawn that still count as day (night_margin_mins) (the same as NIGHT_MARGIN_MINS) | |
--no-update-check | BIRDNET_NO_UPDATE_CHECK | — | A launch-time privacy choice grouped with –offline; updating itself is a button on the System page. |
--noise-classes | BIRDNET_NOISE_CLASSES | Settings → Capture → Detection filters → Noises to watch for (noise_classes) (the same as NOISE_CLASSES) | |
--noise-threshold | BIRDNET_NOISE_THRESHOLD | Settings → Capture → Detection filters → Drop recordings with a barking dog above (0 to 1) (noise_threshold) (the same as NOISE_THRESHOLD) | |
--notify-body-template | BIRDNET_NOTIFY_BODY_TEMPLATE | Settings → Alerts → Notifications → Notification Body Template (notify_body_template) (the same as APPRISE_BODY_TEMPLATE) | |
--notify-confidence | BIRDNET_NOTIFY_CONFIDENCE | Settings → Alerts → Notifications → Notification Min Confidence (notify_confidence) (the same as APPRISE_MIN_CONFIDENCE) | |
--notify-rate-per-minute | BIRDNET_NOTIFY_RATE_PER_MINUTE | Settings → Alerts → Alert limits → At most this many alerts per minute (notify_rate_per_minute) (the same as NOTIFY_RATE_PER_MINUTE) | |
--notify-species-exclude | BIRDNET_NOTIFY_SPECIES_EXCLUDE | Settings → Alerts → Notifications → Never notify for these species (common names, separated by commas) (notify_species_exclude) (the same as APPRISE_WATCHLIST_EXCLUDE) | |
--notify-species-only | BIRDNET_NOTIFY_SPECIES_ONLY | Settings → Alerts → Notifications → Notify only for these species (common names, separated by commas) (notify_species_only) (the same as APPRISE_WATCHLIST) | |
--notify-title-template | BIRDNET_NOTIFY_TITLE_TEMPLATE | Settings → Alerts → Notifications → Notification Title Template (notify_title_template) (the same as APPRISE_TITLE_TEMPLATE) | |
--notify-trigger | BIRDNET_NOTIFY_TRIGGER | Settings → Alerts → Notifications → Notification Trigger (notify_trigger) (the same as APPRISE_TRIGGER) | |
--notify-urls | BIRDNET_NOTIFY_URLS | Settings → Alerts → Notifications → Where to send alerts (notify_urls) (the same as NOTIFY_URLS) | |
--offline | BIRDNET_OFFLINE | — | A launch-time egress policy. The setup page’s model download and location lookup are exactly what it forbids, so it is chosen where the program starts, not in the page it would disable. |
--offsite-backup | BIRDNET_OFFSITE_BACKUP | — | Same as OFFSITE_BACKUP: Offsite backups are encrypted with a passphrase and need credentials. The settings table is inside every backup, so a passphrase or secret key saved there would sit inside the archive it protects; they are read from the environment or the config file only, on purpose (src/helpers/offsite.rs). |
--out | — | — | Only meaningful with –decrypt-backup; see that flag. |
--overlap | BIRDNET_OVERLAP | Settings → Capture → Detection Settings → Analysis Overlap (0–2.9 seconds) (overlap) (the same as OVERLAP) | |
--pipewire-device | BIRDNET_PIPEWIRE_DEVICE | Settings → Capture → Audio sources → Add microphone → PipeWire microphone | |
--post-sunset-offset | BIRDNET_POST_SUNSET_OFFSET | Settings → Capture → Location & Recording Schedule → Extra minutes after sunset (post_sunset_offset) (the same as POST_SUNSET_OFFSET) | |
--pre-sunrise-offset | BIRDNET_PRE_SUNRISE_OFFSET | Settings → Capture → Location & Recording Schedule → Extra minutes before sunrise (pre_sunrise_offset) (the same as PRE_SUNRISE_OFFSET) | |
--privacy-rank | BIRDNET_PRIVACY_RANK | Settings → Capture → Detection Settings → Privacy Rank ({rank_min} = disabled, up to {rank_max}) (privacy_rank) (the same as PRIVACY_RANK) | |
--privacy-threshold | BIRDNET_PRIVACY_THRESHOLD | Settings → Capture → Detection Settings → Privacy Threshold (0 = disabled) (privacy_threshold) (the same as PRIVACY_THRESHOLD) | |
--private-mode | BIRDNET_PRIVATE_MODE | — | Same as PRIVATE_MODE: Putting the whole station behind the sign-in changes who can reach the page. Switched on with no admin password set, every visitor, including the owner, is answered 503 and nobody can sign in; switched on from a page the owner is looking at through a proxy that is not signed in, the page is gone mid-click. It is chosen in the file with the service stopped (hardening guide, section 5). |
--process-existing | — | — | Analyses audio already in the watch directory when the program starts, for batch use; a station that records live never needs it. |
--public-access | BIRDNET_PUBLIC_ACCESS | — | Same as PUBLIC_ACCESS: The list of surfaces that stay open on a private station; it only means anything with PRIVATE_MODE, and has the same lock-out risk. |
--purge-species-floor | BIRDNET_PURGE_SPECIES_FLOOR | Settings → General → Storage → Clips of every species that are never purged (purge_species_floor) (the same as PURGE_SPECIES_FLOOR) | |
--raw-audio-keep-every | BIRDNET_RAW_AUDIO_KEEP_EVERY | Settings → General → Storage → Keep one raw recording in every (raw_audio_keep_every) (the same as RAW_AUDIO_KEEP_EVERY) | |
--rebuild-species-summary | — | — | A repair run once, with the service stopped, after a data fix; the species totals are kept up to date on every write. |
--recording-schedule | BIRDNET_RECORDING_SCHEDULE | Settings → Capture → Location & Recording Schedule → Recording window (recording_schedule) (the same as RECORDING_SCHEDULE) | |
--refresh-extension | — | — | Reinstalls the analytics extension, a repair run with the service stopped. |
--rtsp-url | BIRDNET_RTSP_URL | Settings → Capture → Audio Capture → Network camera address (rtsp_url) (the same as RTSP_URL) | |
--rtsp-urls | BIRDNET_RTSP_URLS | Settings → Capture → Audio Capture → Several network cameras (separated by commas) (rtsp_urls) (the same as RTSP_URLS) | |
--segment-duration | BIRDNET_SEGMENT_DURATION | Settings → Capture → Audio Capture → Segment Duration (seconds) (segment_duration) (the same as SEGMENT_DURATION) | |
--sf-thresh | BIRDNET_SF_THRESH | Settings → Capture → Detection Settings → Species Frequency Threshold (0–1) (sf_thresh) (the same as SF_THRESH) | |
--site-name | BIRDNET_SITENAME | Settings → General → System & Display → Site Name (site_name) (the same as SITENAME) | |
--species-aliases | BIRDNET_SPECIES_ALIASES | — | Same as SPECIES_ALIASES_PATH: A tab-separated file of legacy names kept by hand over months; it needs a file on the station’s disk. |
--station-health-alerts | BIRDNET_STATION_HEALTH_ALERTS | Settings → Alerts → Alert limits → Tell me when the station has a problem (station_health_alerts) (the same as STATION_HEALTH_ALERTS) | |
--stream-max-mb | BIRDNET_STREAM_MAX_MB | Settings → General → System & Display → Streaming Folder Limit (MB) (stream_max_mb) (the same as STREAM_MAX_MB) | |
--stream-retention-secs | BIRDNET_STREAM_RETENTION_SECS | Settings → General → System & Display → Raw Segment Retention (seconds) (stream_retention_secs) (the same as STREAM_RETENTION_SECS) | |
--support-bundle | — | Settings → Health → Diagnostics → Download a support bundle | |
--tls-cert | BIRDNET_TLS_CERT | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-days | BIRDNET_TLS_DAYS | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-dir | BIRDNET_TLS_DIR | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-hostnames | BIRDNET_TLS_HOSTNAMES | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-key | BIRDNET_TLS_KEY | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-listen | BIRDNET_TLS_LISTEN | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-mode | BIRDNET_TLS_MODE | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--tls-redirect | BIRDNET_TLS_REDIRECT | — | Changes how the page itself is served (the address it listens on, HTTPS, the certificate). A wrong value leaves no page to correct it from, so it is chosen where the program is started. |
--twilight-offset | BIRDNET_TWILIGHT_OFFSET | Settings → Capture → Location & Recording Schedule → Extra minutes before sunrise / after sunset (pre_sunrise_offset, post_sunset_offset) | |
--verify-extension | — | — | Checks that the analytics extension loads, a repair step run with the service stopped. |
--watch-dir | BIRDNET_WATCH_DIR | — | Same as RECS_DIR: Where clips are kept (and, when no watch directory is given, where capture segments land). Moving it means moving the files first; a path typed in a form would leave the existing clips behind. |
--web-only | — | — | How the program is launched: it skips the detection engine, so there is no station left for a page to configure. The Capture tab’s model panel says when a station is running this way. |
--weekly-report-schedule | BIRDNET_WEEKLY_REPORT_SCHEDULE | Settings → Alerts → Notifications → Weekly Report Day (weekly_report_schedule) (the same as WEEKLY_REPORT_SCHEDULE) |
Pages with their own forms
Not every change is a setting. These pages change things that are not one value each: lists, accounts, rules, data. Each is under Settings in the browser.
| Page | What it changes |
|---|---|
| Settings → Capture → Audio sources | Microphones and camera streams: add, edit gain and filters, quiet hours, restart one, choose which one Listen live plays |
| Settings → Capture → Which birds count | The exclude, include and always-allow lists, and a confidence threshold for one species |
| Settings → Alerts → Alert rules | Rules that send a message or call a web address when a bird, a time of day or a confidence matches; rules on the station’s own measurements |
| Settings → Alerts → Channels | Send a test through each channel |
| Settings → Data → Backups | Back up now, download, restore, delete a backup |
| Settings → Data → Bring your history with you | Validate and import a BirdNET-Pi database |
| Settings → General → Today Page Layout | Which blocks the Today page shows, and in what order |
| Settings → General → Display | Theme, density and motion, saved on this device |
| Settings → Access → Accounts | The admin password, other people’s accounts, signed-in sessions |
| Settings → Health → System | Restart the station; check for and apply an update |
The setup wizard (/onboarding) | First-run: the admin password, the bird-identifying model, location, name and language, microphone, accuracy and alert presets |
Things that are not settings
- The station’s clock and time zone belong to the operating system. The setup wizard records the zone it finds so Diagnostics can tell you when the system’s disagrees, and says the one command that fixes it; the program cannot change them (it does not run as root).
- Units and the clock face are settings: the temperature unit (Celsius or Fahrenheit) and the 12- or 24-hour clock are under Settings → General → System & Display (
temperature_unit,time_format), and each browser can also choose its own in Display preferences. - Forgetting the admin password is recovered from the device itself: the reset link is printed on the station’s own console, so that someone who can only reach the page cannot take the station.
Web UI & URLs
A map of the pages and admin URLs the dashboard serves (not the /pages/* fragments the pages load into themselves, nor /static/*). For the JSON/WebSocket API, see HTTP & WebSocket API.
The six homes
The UI is organized into six top-level homes — the tabs across the top of every page (and the phone bottom bar).
| URL | Home | What it gathers |
|---|---|---|
/ | Today | the merged “right now” view + the full searchable day (live signal, day strip, unified log, top species, best recordings, station health) |
/species | Species | species list · photos · life list, plus per-species detail |
/patterns | Patterns | when-active heatmap · dawn chorus · migration · co-occurrence · trends · behavioral analytics, one tab each |
/recordings | Recordings | the Clips browser (?view=clips) and the live audio + spectrogram (?view=live) |
/reports | Reports | weekly recap · year in review · history |
/station | Settings | health (public) + the gated admin task groups |
Searching the whole log
/search is the detection log across all time rather than one day. Every filter
lives in the query string, so a search worth repeating is a bookmark:
/search?q=Turdus&from=2026-03-01&to=2026-03-31&conf_min=80&sort=confidence
| Parameter | Takes | Notes |
|---|---|---|
q | free text | matches common and scientific name; a leading NOT inverts it |
species | one name | exact, rather than q’s substring match |
from, to | YYYY-MM-DD | inclusive both ends |
hour_from, hour_to | 0–23 | hour_from above hour_to asks for a window through midnight |
conf_min, conf_max | 0–100 | a percentage, because that is what the UI shows everywhere else |
source | audio source label | which microphone or stream |
verdict | confirmed · rejected · unreviewed | |
locked | locked · unlocked | whether the clip is protected from retention |
category | the Today log’s four shortcuts | rare, new, verified, quarantined |
sort | oldest · confidence · confidence-asc · species · species-desc | newest first when absent |
offset | a row number | 50 rows per page |
With an admin session it also carries bulk review: tick rows and confirm,
reject or delete up to 100 at once. Those actions are POSTs on the gated
router, so a logged-out visitor sees the same search results and none of the
buttons.
Tabs within a home are selected by a query parameter, e.g.
/patterns?tab=dawn, /reports?tab=history, /species?view=lifelist,
/recordings?view=live.
Legacy routes (permanent redirects)
Older addresses and BirdNET-Pi muscle-memory still work — each one
308-redirects to its place in the new structure, so no bookmark ever 404s.
| Old URL | → New |
|---|---|
/today | / |
/gallery | /species?view=photos |
/life-list | /species?view=lifelist |
/heatmap | /patterns |
/analytics/dawn-chorus | /patterns?tab=dawn |
/migration | /patterns?tab=migration |
/correlation | /patterns?tab=together |
/timeseries | /patterns?tab=trends |
/analytics | /patterns?tab=behavior |
/weekly | /reports |
/year-in-review | /reports?tab=year |
/history | /reports?tab=history |
/system | /station |
/listen | /recordings?view=live |
/livestream | /recordings?view=live |
/live | /recordings?view=live |
Other pages
| URL | Description |
|---|---|
/species/detail?name=… | Species detail — hourly chart, trend, companions, photo |
/quarantine | Rare-bird review — approve, reject (also surfaced as the Today review nudge) |
/notifications | Notification center — history and channel stats |
/kiosk | Kiosk mode — auto-refreshing display for dedicated screens |
/onboarding | First-run setup wizard |
/detections/detail?date=&time=&name= | Single-detection detail — spectrogram, audio, correlation id, share |
/detection-reviews | The review triage queue — confirm or reject detections one by one (Reviewing Detections) |
/reports/day?date= | One day’s look-back, opened from the History calendar’s Open day link |
/player/{filename} | A clip in the custom audio player with its spectrogram; ?species=, ?confidence=, ?time= and ?date= only label it |
/system/changelog | The release history, rendered from the CHANGELOG.md compiled into the binary; /system/changelog/latest is the “new in this version” banner it feeds |
Admin
Sign-in (a session cookie) is required, when an admin password is set — a
fresh bare-metal install sets one automatically — for the /admin* routes, the
five Settings management tabs and every page action that changes something (the
Today delete/relabel/lock posts, detection reviews, recordings lock/unlock/delete,
quarantine approve/reject/delete, the onboarding save and bulk review from
Search). The Settings home’s Health tab (/station) is public — the
operator-grade vital-signs surface that replaced the read-only /system page,
which now redirects to it. The management tabs are the gated pages; the old
/admin/audio, /admin/species, /admin/notifications, /admin/rules,
/admin/quality, /admin/migrate, /admin/backups, /admin/accounts and
/admin itself 308-redirect into them. Every home above, the read-only
JSON/WebSocket API below and the feeds are open. See
Remote Access & Security.
| URL | Description |
|---|---|
/station/capture | Audio sources (#audio) and species lists (#species) — the old /admin/audio and /admin/species redirect here |
/station/alerts | Notifications (#notifications) and the alert-rule engine (#rules) — was /admin/notifications, /admin/rules |
/station/data | Data quality (#quality), BirdNET-Pi import (#import), backups / restore / danger zone (#backups) — was /admin/quality, /admin/migrate, /admin/backups |
/station/settings | General settings (the tab is labelled General) |
/station/access | Accounts and access (#accounts) — was /admin/accounts |
/admin/settings | The all-in-one form: audio, location, detection, notifications, email, MQTT, species, system |
/admin/system | CPU / memory / temperature / disk |
/admin/overview | Health-detail overview |
/admin/doctor | The --doctor report rendered in the browser; /admin/doctor.json and /admin/support-bundle beside it |
/admin/species/manage | Species storage — per species: exclude, delete detections, delete clips (Settings & Detection) |
/admin/images | Species photographs — the blacklist of image URLs that are never shown |
/admin/audit | Audit log (180 days) |
/admin/system/logs/page | Live log viewer (SSE, level filtering) |
/admin/update/check | Check GitHub Releases for a newer version (JSON). Applying is a separate POST /admin/update/apply with no UI button; the panel’s Check for Updates button points you at install.sh instead |
Public links & feeds
These are unauthenticated, read-only endpoints. See Detection Detail & Sharing and RSS & Calendar Feeds.
| URL | Description |
|---|---|
/r/<token> | Public share page for one detection (HMAC-signed, 30-day expiry) |
/r/<token>/audio.wav | Redirect to the shared clip’s audio |
/r/<token>/spectrogram.png | Redirect to the shared clip’s spectrogram |
/feeds/rare.rss | RSS 2.0 — first-of-station detections (confidence > 0.85) |
/feeds/rare.ics | iCalendar — the same rare detections as calendar events |
/feeds/today.rss | RSS 2.0 — every detection today |
API
The JSON API is versioned under /api/v2, with a WebSocket live stream at /api/v2/ws/detections. It has its own page: HTTP & WebSocket API — endpoints, query parameters, response shapes and examples.
HTTP & WebSocket API
Everything the UI does is backed by a versioned JSON API under /api/v2. It’s handy for dashboards, scripts, and home-automation pulls. Almost every endpoint is a read-only GET; the exceptions are the nineteen token-gated endpoints — eleven writes and eight reads — under Changing a station.
Base URL in the examples is
http://localhost:8502. Adjust for your host, and remember any reverse-proxy auth you’ve added.
Auth: the built-in session sign-in gates the
/admin*UI routes, the/station/<tab>management pages and the page actions that change something — none of which live under/api/v2. Every read endpoint under/api/v2/*, the WebSocket stream, and the health check are open to anyone who can reach the port — restrict them at the network layer (VPN / proxy allow-list) if that matters, or turn on private mode, after which every read endpoint and both WebSockets answer401without a session (the health check, and/api/v2/metricswhenmetricsis carved out, stay open).The write endpoints, and the eight token-gated reads, are the exception and do not follow that rule: each needs
Authorization: Bearer <token>, and a station with noBNB_API_TOKENanswers404to all of them. See Changing a station.
OpenAPI: a machine-readable OpenAPI 3.1 description of the JSON API is served at
GET /api/v2/openapi.json(and committed atcrates/birdnet-web/openapi.json). Load it into Swagger UI, Redoc, Postman, oropenapi-generatorto explore the endpoints and generate clients. It describes every route served under/api/v2, and a test holds it to that:every_route_the_api_serves_is_documentedreads the routes from the router itself (axum’sDebuglisting of every router the server merges: public, admin and token-gated) and fails, naming each, on a served method and path that the document omits;every_documented_operation_is_servedfails on one the document names that no route serves; andthe_document_is_structurally_validchecks that every$refresolves, every path placeholder is a declared path parameter, and every operation has a uniqueoperationId, a summary and a described response. Two things are not in it because they are not under/api/v2: the live audio stream at/stream, and the feeds and share pages. The two WebSockets,/api/v2/ws/detectionsand/api/v2/ws/spectrogram, are described by the status that upgrades them,101, with the messages they carry. The API root is/api/v2exactly, and/api/v2/is a404; an OpenAPI path must begin with/, so the root is the one path item that names its own server (/api, path/v2) rather than being spelled/. The structural check is a small one of our own and is not Redocly’s rule set (redocly.yamlis in the repository root); runredocly lint crates/birdnet-web/openapi.jsonto apply that.
Health & metrics
curl http://localhost:8502/api/v2/health
{
"status": "healthy",
"version": "0.18.0",
"database": "ok",
"analytics": true,
"detection_daemon": "running",
"detection_writes": "accepted",
"detection_silence_secs": 142,
"analysis_queue_depth": 2,
"detection_deadman": "ok",
"classifiers": [
{
"model": "birdnet",
"state": "ok",
"consecutive_failures": 0,
"failure_threshold": 10,
"windows": 48210,
"last_window_at": 1788973195,
"last_success_at": 1788973195,
"error_class": null
}
],
"data_volume": {
"writable": true,
"mount": "intact",
"disk": "ok",
"used_percent": 35.2,
"checked_at": 1788973200
},
"admin_bootstrap": "ok",
"boot_anomalies": [],
"strict": false
}
status is "healthy" (HTTP 200) or "degraded" (HTTP 503 — the database
is unreachable, the last recorded integrity check failed, or the data volume is
not taking writes), so monitoring can alert on the status code alone.
data_volume is the last of a once-a-minute probe of the data directory
("unchecked" before the first): whether a test write succeeded (writable,
with write_error when it did not — a read-only remount after I/O errors and
a card full to the byte both fail it), whether the directory is still its own
mount (mount is intact, vanished, not-a-mount for a directory that was
never a separate filesystem, or unknown), and the df verdict (disk is
ok, low, critical or unknown). writable: false or mount: vanished
is degraded on every reading: the station is running and keeping nothing.
disk: critical or unknown, and admin_bootstrap: failed, are reported as
degraded only under ?strict=1.
analysis_queue_depth is how many raw segments were waiting for analysis
after the daemon’s last sweep (null before it reports). Above 40 the daemon
analyses one segment in two rather than fall further behind, counts the rest
in birdnet_segments_shed_total, and raises the backlog station-health
condition; see the deployment chapter for the policy.
detection_deadman is the detection deadman’s own verdict: tripped while it
has an open quiet episode (no detections for longer than its threshold, or
never, once the station has listened that long), ok otherwise, off when
its threshold is 0. tripped is degraded under ?strict=1, so a monitor
polling the strict endpoint goes red at the moment the notifier fires rather
than reading detection_silence_secs against a threshold of its own.
classifiers is each loaded classifier, primary first (empty until the
daemon has analysed a file). state is failing once a classifier has failed
failure_threshold analysis windows in a row and answered none since, and
ok otherwise; error_class says why the latest failed window failed —
inference_error (the model returned an error) or non_finite_output (it
answered with NaN or infinite scores, which are never recorded). Times are
seconds since the Unix epoch, null for never. A failing classifier is
degraded under ?strict=1 and raises the classifier:<id> station-health
condition. On a station with several classifiers the others keep recording
while one fails, which is why the detection deadman cannot be relied on to
notice.
boot_anomalies is what the boot journal found this start changed since the
last one: db_lost (the database held detections at the last start and holds
none, or is absent, now), db_path_changed, mount_lost (the data directory
was its own mount and is not now — the volume did not mount, and the station
is writing to the disk beneath it), version_rollback, config_reverted (the
configuration file has errors and the station is running on the last one a
start succeeded on), config_rejected (errors and no last-good copy: the
station is running web-only so this report is reachable) and audio_card_moved
(an ALSA device addressed by card index, plughw:1,0, resolves to a different
card than at the last start: the index moved on re-enumeration and the station
is recording from another device; address the card by id). The journal is kept in
the configuration directory, outside the data volume, so a volume that fails
to mount cannot take the memory of the last start with it; before it, such a
start was indistinguishable from a first run. A non-empty list is degraded
under ?strict=1 and raises the boot-anomaly station-health condition.
database is "ok", "unchecked" or "error". It reports the verdict of the
daily maintenance integrity check, not a check run at request time: that
check reads every page of the database file, and this endpoint is polled by the
container health check every 30 seconds. On a multi-year station a per-request
check could not finish inside the health check’s own timeout. "unchecked"
means no verdict is on record yet — normal for the first few minutes after a
fresh install, and reported healthy/200, because “not yet verified” is not
“broken”. A failure stays reported until it is fixed, rather than depending on
which request happened to catch it.
analytics_mirror_failures counts detections the database accepted and the
DuckDB analytics copy refused since the process started; a non-zero value
means the behavioural dashboards are behind until the startup drift check
rebuilds the copy, and raises the analytics-mirror station-health condition
while the failures are recent. detection_daemon is "running" or "stopped" — "stopped" means web-only
mode or an unconfigured model/labels/watch-dir, i.e. the UI is up but nothing is
being analysed. analytics reports whether the DuckDB engine is active.
detection_silence_secs is the end-to-end freshness signal: seconds since the
most recent stored detection (the deadman watchdog’s measurement), or null
before the first measurement / on a station that has never detected anything.
A value that climbs past your expected quiet period means the chain from
microphone to database has stopped producing rows even if every component
looks healthy.
| Endpoint | Purpose |
|---|---|
GET /api/v2/health | JSON liveness/health check (use for monitoring). |
GET /api/v2/health/conditions | What is wrong right now: the station-health conditions (a dead microphone, a full disk, a failing backup, a lost data volume, a boot that lost something) as the notifier last evaluated them, with evaluated_at. These used to be push-only. Always 200; /health carries the verdict. |
GET /api/v2/metrics | Prometheus metrics (text format) — see Integrations. |
GET /api/v2/stats | Summary counts (detections, species, today). |
GET /api/v2/system/disk | Disk usage for the data directory. Answers 503 when the disk is critical (95 % used) and 200 otherwise. Fullness is measured against the space this user can actually reach — used / (used + available), the same figure df’s Use% column reports — not against the raw device size, which on any ext4 with its default 5 % root reserve, or inside a container quota, is larger than anything the station can use. |
Detections
curl 'http://localhost:8502/api/v2/detections/recent?limit=2'
{
"detections": [
{
"date": "2026-05-22", "time": "21:10:31",
"com_name": "Song Sparrow", "sci_name": "Melospiza melodia",
"confidence": 0.83, "cutoff": 0.7,
"lat": 42.3601, "lon": -71.0589,
"week": 21, "sens": 1.25, "overlap": 0.0,
"file_name": "Song_Sparrow-83-2026-05-22-...wav"
}
]
}
| Endpoint | Common query params |
|---|---|
GET /api/v2/detections | date, species (common or scientific name), source, limit, offset — paginated, newest first; the filters combine, and total_count counts every page of the filtered set |
GET /api/v2/detections/recent | limit — most recent first |
GET /api/v2/detections/daily | days — per-day counts over the last N days |
GET /api/v2/detections/export | format (csv or json, default csv), from, to (inclusive YYYY-MM-DD) — the detection history as a download (detections.csv); more than 1 000 000 rows is a 413 that asks you to narrow the range |
GET /api/v2/detections/export/birddb | from, to — BirdNET-Pi’s semicolon-separated BirdDB.txt (BirdDB.txt), the twelve BirdNET-Pi columns and nothing more |
GET /api/v2/detections/export/ebird | date, lat with lon, location, min_confidence, protocol, observers, state, country, complete — eBird Record Format (ebird_export_….csv) |
The rest of the export family (detections/export/raven, the per-clip Raven and Audacity tables) is under Export, and the exports’ columns are described on the Backups page.
Analysis runs
GET /api/v2/analysis-runs?limit= lists the station’s analysis runs, newest first (limit defaults to 50, at most 500), as {"runs": [...]}, and GET /api/v2/analysis-runs/{id} returns one (or 404). A run is one detection-daemon start: it records the SHA-256 of the model and labels it analysed with and the settings in force, and carries detections, the number of rows that name it. A detection row’s run_id points at one of these; the exports resolve it to the model’s name and checksum, and this is where the rest (when the run started, the label count, the geomodel) is read.
Languages
GET /api/v2/languages lists the languages species common names can be shown in, as {"languages": [{"code", "name", "native_name", "active", "coverage"}], "count", "active"}. coverage is {"named", "of"}, how many of the installed classifier’s species the language names, and null for English and when no classifier label file was available to count against. See Species Names in Your Language.
Species
| Endpoint | Common query params | Purpose |
|---|---|---|
GET /api/v2/species/top | limit | Most-detected species with counts |
GET /api/v2/species/search | q, limit | Search by name |
GET /api/v2/species/detail | name | Per-species stats |
GET /api/v2/species/activity | date | Hourly activity (all species) on a given day |
GET /api/v2/species/occurrence | week (1–48), q, admitted (true/false), limit (default 100, at most 1000), offset | What the station’s range (occurrence) filter says about each species for its place and a week: the model’s probability, the threshold, whether the station admits the species and why. See Why isn’t a bird heard here? |
GET /api/v2/species/export | format (csv or json), limit | Per-species totals as a download (species.csv) |
GET /api/v2/species/image/{scientific_name} | — | The image’s metadata (URL, cache status, description) as JSON, or 404; it does not download the picture. Only species the station has detected are looked up |
GET /api/v2/species/image/{scientific_name}/file | — | The cached picture itself (a custom image first, then the provider’s), with a five-minute private cache header on the picture and on the 404 |
GET /api/v2/recordings | species (matched against the file name), limit (default 50, at most 500), offset | List the files in the recordings directory: filename, size_bytes, modified_secs, content_type |
GET /api/v2/recordings/{filename} | — | Stream a recorded clip, honouring Range requests (206); the type follows the extension (WAV, MP3, FLAC, Ogg, AAC) |
GET /api/v2/spectrogram/{filename} | species, confidence, time (the overlay label), thumb=1 (a small bare preview) | A PNG spectrogram of that clip, rendered on demand and cached, in the station’s spectrogram_colormap and spectrogram_range_db; with the raw_spectrogram setting on, never labelled. ETag and Cache-Control: private, no-cache; a matching If-None-Match is 304 |
GET /api/v2/dashboard-image | — | The file named by the dashboard_image setting, as the PNG, JPEG, GIF or WebP it is by its first bytes; 404 for anything else, a missing file or one over 8 MiB. ETag, private, no-cache, 304 on a match |
Why isn’t a bird heard here?
GET /api/v2/species/occurrence answers it with the number behind it. The station asks a range model which species occur around its location in a week of the model’s 48-week year (four weeks to a month) and admits a species when the model scores it at or above the threshold (SF_THRESH, default 0.03), or you put it on the Always allowed list, and your include and exclude lists allow it. The response is every species the model scores, most likely first, each with that decision:
{
"location": { "latitude": 52.5, "longitude": 13.4 },
"week": 22, "week_is_current": false, "threshold": 0.03,
"scored": 6522, "admitted_total": 412, "matched": 1, "limit": 100, "offset": 0,
"species": [{
"scientific_name": "Spiza americana", "common_name": "Dickcissel",
"display_name": "Dickcissel", "probability": 0.0004, "admitted": false,
"verdict": "held-back-by-range",
"explanation": "Held back: the range model gives it <0.01 % here in week 22; this station admits species at 3 % and above."
}]
}
verdict is admitted, admitted-by-whitelist (under the threshold, allowed by the Always allowed list), held-back-by-range (under the threshold) or held-back-by-lists (over it, kept out by your include or exclude list). week defaults to the current week; q matches either name, or the name in the station’s language; admitted=false is the held-back species. A week outside 1–48, an admitted that is not true or false, and a limit outside 1–1000 are a 400 that names the range and does not run the model (the model reads a week it does not know as “no seasonal information” and answers with a year-round list, so the value is refused rather than clamped).
A station that cannot say answers 503 with a reason and no species key, because an empty list would read as “nothing occurs here”: no-location (the station has no coordinates), no-range-model (no range model is loaded: the filter is off and every species the classifier knows is a candidate), range-model-failed (the model is there and would not run; the log has the reason), or no-detection-daemon (this process does not run the detection daemon). The coordinates are rounded to one decimal place (about 11 km), so this endpoint adds no precision of its own; that is not a way of hiding the location, which the public pages already show more exactly (sunrise and sunset times on Today and the Dawn chorus, and each detection’s own latitude and longitude). The same answer is the page at /species/occurrence, linked from the Species page.
Time-series & analytics
These power the charts on the Analytics and time-series pages. The richer ones use the DuckDB analytics engine, which is built in and on by default (Configuration); they return a “not available” status only if you’ve disabled it.
| Endpoint | Purpose |
|---|---|
GET /api/v2/timeseries/daily · weekly · hourly | Counts over time |
GET /api/v2/timeseries/diversity | Species diversity over time |
GET /api/v2/timeseries/trend · year-over-year | Trends and year-on-year comparison |
GET /api/v2/timeseries/peak-windows · gaps · anomalies | Peak activity, quiet gaps, outliers |
GET /api/v2/timeseries/accumulation | Life-list accumulation curve |
GET /api/v2/timeseries/heatmap · sessions | Hour×day heatmap, activity sessions |
GET /api/v2/timeseries/status | Whether the time-series engine is available |
GET /api/v2/analytics/sessions · retention · funnel · next-species · previous-species · patterns | DuckDB behavioral analytics |
GET /api/v2/analytics/funnel-events | The window_funnel_events variant of funnel, with the same parameters (species, a comma-separated sequence of at most 64 names; window; hour_start, hour_end) |
GET /api/v2/analytics/sequence-count · sequence-match-events | The sequence_count and sequence_match_events variants of patterns, with the same parameters (species, max_gap, hour_start, hour_end) |
GET /api/v2/analytics/status | Analytics build flags and the state of the store |
GET /api/v2/insights/dawn-chorus?period_days=&min_days= | Each species’ typical first song of the morning (mean and earliest first detection between 04:00 and 10:00), over species heard on at least min_days (default 3) of the last period_days (default 30, at most 365) days, earliest first. A value that is not a positive integer is a 400; one over the limit is clamped, and both values applied are echoed. |
GET /api/v2/insights/migration | Species first heard in the last 14 days, and species with at least 5 detections and none in the last 14 days, with when and how often. |
GET /api/v2/insights/phantom-species | Species detected at least 3 times in the last 30 days with a mean confidence under 0.6, least confident first — often a noise source the model keeps mistaking for a bird. Rejected detections do not count. |
GET /api/v2/insights/expected-today | Species heard within 3 days either side of today’s date in any of the last 10 years, most regular first, with years_of_data. |
GET /api/v2/insights/expected-today/regional | What eBird observers reported nearby that this station does not usually hear now: the eBird snapshot less the list above. available is false without a snapshot (eBird). |
GET /api/v2/dashboard/kpis | Species ever heard, detections today, the busiest day since this date last year (best_day), and the run of consecutive days with a detection ending today (detection_streak; 0 when today has none). With none, best_day is {"date": "", "count": 0}. |
GET /api/v2/analytics/species/summary?start_date=&end_date=&limit= | Each species’ count, mean and highest confidence, and first and last detection (first_heard, last_heard: RFC 3339 with the offset in force at the time) over the range, most detected first, as a bare array. Either end may be left open; a date that is not a calendar day, or a reversed range, is a 400. |
GET /api/v2/analytics/species/detections/new?start_date=&end_date=&limit=&offset= | Species first heard here within the range (default: the 30 days before today through today), newest first, with count_in_period, as a bare array. limit defaults to 100 (0 also means 100); a limit or offset that is not a non-negative integer is a 400. |
GET /api/v2/analytics/species/daily?date=&min_confidence=&limit= | One day’s species (default today), most detected first and ties by the later last detection, as a bare array. Each has scientific_name, common_name, count, 24 hourly_counts, high_confidence (a detection at 0.8 or more), current_season, and — left out when zero, false or empty — max_confidence, first_heard, latest_heard, thumbnail_url, is_new_species, is_new_this_year, is_new_this_season (first heard here, since 1 January, or since the season began, at most 7 days before), days_since_first_seen, days_this_year, days_this_season and days_since_last_seen (back to the previous day it was heard). All read as of date. Seasons follow the hemisphere: north of 10° spring/summer/fall/winter from 20 March, 21 June, 22 September and 21 December; south of −10° the same names six months on; between, and with no location, wet1/dry1/wet2/dry2 from the 1st of March, June, September and December. min_confidence is a percentage (anything that is not a number is 0); limit defaults to 30. |
GET /api/v2/analytics/sources?start_date=&end_date= | The audio sources with detections in the range (all history when it is open), busiest first, as {"sources": [{"id", "name", "count"}]}. id is the stream id each detection records; name is the name given on the Capture screen, or the id. Rejected detections, and detections from before sources were recorded, are in no count. A bad or reversed range is a 400. |
GET /api/v2/analytics/confidence/distribution?start_date=&end_date=&species=&bins=&limit= | Each species’ confidence distribution: [{"scientificName", "commonName", "bins", "total"}], where bins is the fraction of its detections in each of bins equal-width bins over 0–1 (a bin holds its lower edge; 1.0 is in the last). start_date is required; end_date defaults to 30 days after it. bins defaults to 20 and is held to 5–50. Without species, the limit most detected species (default 5, at most 10; anything else is 5), less those with fewer than 20 detections in the range; with species (scientific or common name, any case), that species alone. |
GET /api/v2/analytics/time/year-over-year?date= | Detections from 1 January to date (default today), cumulatively, against the same span of the year before: {"currentYear", "previousYear", "points": [{"date", "monthDay", "thisYear", "lastYear", "delta"}]}, one point a day. Aligned by calendar date: on 29 February, last year’s figure is its 28 February; last year’s 29 February counts from its 1 March. A date that is not a calendar day is a 400. |
GET /api/v2/analytics/time/dawn-onset?start_date=&end_date=&species= | One row per day of the range — quiet days too — as [{"date", "onsetRelMinutes", "detectionCount"}]. onsetRelMinutes is the day’s third-earliest detection, in minutes after civil dawn (negative before it), and null on a day with fewer than five detections, a day the sun never gets 6° below the horizon, or at a station with no location set. start_date is required; end_date defaults to 30 days after it; species is a scientific or common name. |
GET /api/v2/analytics/time/heatmap?start_date=&end_date=&species=&format= | Detections by day and time of day: {"dates", "slotResolutionMinutes", "cells": {"dateIndex", "slot", "count"}}. dates lists every day of the range; the three cell arrays run in parallel and list only occupied cells, by day and then slot; slot n starts n × slotResolutionMinutes minutes after local midnight. Slots are 15 minutes up to 90 days, 30 up to 180, then 60. format=csv answers the same cells as date,slot,slot_start,count. Range rules as above. |
GET /api/v2/analytics/time/distribution/species?start_date=&end_date=&species=&limit= | Each species’ detections by local hour as fractions of its total: [{"scientificName", "commonName", "buckets", "total"}], 24 buckets from midnight. The limit most detected species (default 5, at most 10; anything else is 5), or those named by a repeated species (?species=A&species=B, scientific or common names, at most 10 — eleven is a 400). Range rules as above. |
GET /api/v2/analytics/time/succession?start_date=&end_date=&species=&limit= | The same species’ hourly detections as counts: [{"scientificName", "commonName", "counts", "total"}]. Default 6 species, at most 10. |
These insights, dashboard and analytics/species endpoints read SQLite directly, so they answer with the analytics engine off. Their parameters, defaults and field names are birdnet-go’s, so a client written for either station reads both. A station whose detections cannot be read answers 500 rather than an empty list.
Diagnosing empty analytics dashboards
GET /api/v2/analytics/status is the first thing to check when the analytics
screens are blank. Its analytics_compiled and analytics_configured fields
describe intent — that the binary has DuckDB in it and a database was wired
up — and both stay true in every case where the dashboards are actually
broken. The store object is the part that differs:
{
"analytics_compiled": true,
"analytics_configured": true,
"store": {
"extension_loaded": true,
"self_test": "ok",
"detections": 412903,
"unplaceable_detections": 3,
"detections_placeable": 412900,
"engine_duckdb_version": "v1.5.6",
"engine_platform": "linux_arm64",
"embedded_extension": {
"version": "v0.10.0",
"duckdb_version": "v1.2.0",
"abi": "C_STRUCT",
"platform": "linux_arm64",
"mismatch": null
}
}
}
extension_loaded: false— the behavioural functions (sessionize,retention,window_funnel,sequence_*) will fail while the time-series screens keep working. Checkembedded_extension.mismatch: a non-null value means the copy built into this binary can never load, which leaves an offline station with no behavioural analytics. Itspropertysays whether theDuckDB version, theplatform, or both disagree — compare againstengine_duckdb_versionandengine_platform. An extension is locked to both, and both fail the same way atLOAD. Howduckdb_versionis read depends onabi: forCPPandC_STRUCT_UNSTABLEit is a DuckDB version that must equal the engine’s; forC_STRUCT(behavioral v0.10.0 and later) it is a C API version, and any engine of the same major at or above it accepts the extension — sov1.2.0against av1.5.6engine is correct. Rebuild againstcommunity-extensions.duckdb.org/<engine_duckdb_version>/<engine_platform>/.self_testother than"ok"whileextension_loadedistrue— the extension loaded but one of its functions answered a fixed check differently from the version this build’s queries were written for; the message names the function. The behavioural cards may show wrong numbers rather than errors, so treat it as broken.birdnet-behavior --verify-extensionruns the same checks.detections: 0against a station with history — the SQLite → DuckDB sync has not run or did not complete.detections: null— the count itself could not be read from the store, which is a store fault rather than a sync that has not run.unplaceable_detectionsabove zero — that many rows carry aDate/Timenaming no point in time (usually from a BirdNET-Pi import). They count toward the station’s detection total but cannot appear in any date- or time-based analytic, which is why a dashboard total can sit below the raw count.store: null— this binary was built without analytics.
WebSocket — live detections
ws://localhost:8502/api/v2/ws/detections
Connect and you receive a JSON message for each new detection as it happens — the same stream that drives the dashboard live feed, the spectrogram and kiosk mode. Behind a reverse proxy, make sure the Upgrade/Connection headers are forwarded (Remote Access).
const ws = new WebSocket("ws://localhost:8502/api/v2/ws/detections");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
Live audio
GET /stream is the live audio as MP3 (?source_id=, ?freq_shift_hz=),
outside /api/v2. The HLS variant uses birdnet-go’s routes and fields, so a
client written for either station reads both:
| Request | Answer |
|---|---|
POST /api/v2/streams/hls/{source_id}/start | {status, source, playlist_url, active_clients, playlist_ready, freq_shift_hz, stream_token} once the playlist lists two segments, or with status: "starting" after 20 s |
GET /api/v2/streams/hls/t/{token}/playlist.m3u8 | the live playlist; its segments are …/t/{token}/seg<n>.ts |
POST /api/v2/streams/hls/heartbeat | {"status": "ok"}; with ?disconnect=true the session leaves |
POST /api/v2/streams/hls/{source_id}/stop | the session in the body leaves; the stream ends with its last |
GET /api/v2/streams/hls/status | {count, streams}, without tokens |
source_id is a source’s id or default. Start, stop and heartbeat take an
optional {"session_id": "…"} body naming the caller — a value of its own
choosing, up to 64 characters — and start and stop take
?freq_shift_hz= as /stream does; each shift is a stream of its own. The
playlist URL is relative to the station’s root, so a station under a base
path serves it beneath that path.
url=$(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"session_id":"cli"}' \
http://localhost:8502/api/v2/streams/hls/default/start | jq -r .playlist_url)
ffplay "http://localhost:8502$url"
The errors are /stream’s: 404 for an unknown source, and 503 when no
source is configured, the source is not recording, all four live-stream slots
are in use, or ffmpeg is not installed.
Errors
An unmatched path under /api/ returns a machine-readable JSON 404 (not the
HTML “page not found” the browser UI serves), so a script that mistypes a route
or hits a removed endpoint sees the failure instead of silently parsing a web
page:
{ "error": "not found", "path": "/api/v2/nope" }
Export
CSV/JSON/eBird export of the full detection history is available from the Backups page (and a BirdNET-Pi-compatible CSV for tooling that expects that format).
For verification tools: GET /api/v2/detections/export/raven?from=&to= is a Raven selection table over every detection with a clip, in BirdNET-Analyzer’s column layout with Begin Path naming the clip; GET /api/v2/recordings/{clip}/raven.txt is one clip’s table and GET /api/v2/recordings/{clip}/labels.txt the same selections as an Audacity label track. All three read the reviewed set, so a rejected detection is in none of them; a clip no detection names is 404. See Export for how each selection is placed.
Changing a station
Ten method-and-path pairs, and they are the only ones in /api/v2 that change
anything.
They exist so Home Assistant, Node-RED or a shell script can act on a station
rather than only read it — before them, every state change in the product was an
HTMX form post returning HTML, which is not a contract anyone can build on.
Eight reads live behind the same token, because what they return is not public:
GET /api/v2/settings (the configuration, credentials taken out),
GET /api/v2/system/capture and GET /api/v2/system/jobs (operational detail
about the station and its sources), GET /api/v2/system/network and
GET /api/v2/system/processes (the network it sits on, and what it is running),
GET /api/v2/system/database/overview (where its database is and what is in
it), GET /api/v2/models/catalog, and GET /api/v2/detections/comments.
Turning them on
They are off by default. Set BNB_API_TOKEN in the config file or the
environment and restart:
openssl rand -base64 48
Until you do, all nineteen answer 404: the write surface does not exist rather
than existing unprotected. Note this is the opposite default from CADDY_PWD,
where an unset password leaves /admin open — an unset token leaves the write
API closed. A token shorter than 32 bytes is refused and leaves the API off,
with a warning in the log and a warning from birdnet-behavior --doctor.
Identifying a detection
There is no surrogate id. A detection is identified the way the database identifies it — by date, time, scientific name and clip:
{ "date": "2026-09-03", "time": "06:12:44", "sci_name": "Erithacus rubecula",
"file_name": "European_Robin-91-2026-09-03-birdnet-06:12:44.wav" }
file_name is the detection’s file_name as GET /api/v2/detections
returns it ("" for a detection with no clip). It is optional, and worth
sending: on a station with more than one microphone or camera, two sources
that hear the same bird in the same second record two detections with the
same date, time and species, and only the clip tells them apart. Without it,
lock and unlock act on every such detection, and delete removes a lone
one but leaves any that are locked. A review applies to all of them either
way: verdicts are still recorded per date, time and species.
A malformed key is 400; a well-formed key matching no row is 404.
The endpoints
| Method | Path | Does |
|---|---|---|
POST | /api/v2/detections/review | Record confirmed / rejected, or clear a verdict by omitting status |
POST | /api/v2/detections/lock | Protect the clip from the disk-full purge and retention sweep |
POST | /api/v2/detections/unlock | Return it to the ordinary purge rules |
POST | /api/v2/detections/delete | Remove the detection row |
POST | /api/v2/detections/batch | Apply one of the four above to up to 500 detections |
GET | /api/v2/detections/comments | Read one detection’s comment thread, oldest first |
POST | /api/v2/detections/comments | Add a comment (1–2000 characters) |
POST | /api/v2/detections/comments/delete | Withdraw one comment by id |
POST | /api/v2/dynamic-thresholds/reset | Forget what the station learned about one species ({"species": "Strix aluco"}) or all of them ({"all": true, "confirm": true}) |
GET | /api/v2/settings | Read every setting, with credentials redacted |
PUT | /api/v2/settings | Change one or more settings |
POST | /api/v2/control/restart | Restart the station |
POST | /api/v2/control/restart-source | Restart one audio source’s capture |
GET | /api/v2/system/capture | Per-source capture health, as Station Health draws it |
GET | /api/v2/system/jobs | Every scheduled maintenance job and its state |
GET | /api/v2/system/network | Network interfaces: addresses with prefix, hardware address, bytes in and out; loopback last |
GET | /api/v2/system/database/overview | The database: engine settings, each table’s rows and size, the last recorded integrity check and VACUUM, mean detection-write latency, and detections in each of the last 24 UTC hours |
GET | /api/v2/system/processes | The station’s process and everything it started (capture ffmpeg, arecord, conversions), CPU sampled over 250 ms as a percentage of one core |
GET | /api/v2/models/catalog | The classifiers this build can install |
curl -X POST http://localhost:8502/api/v2/detections/review \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date":"2026-09-03","time":"06:12:44","sci_name":"Erithacus rubecula","status":"confirmed"}'
curl -X POST http://localhost:8502/api/v2/detections/lock \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date":"2026-09-03","time":"06:12:44","sci_name":"Erithacus rubecula"}'
Comments
A verdict records what the station decided; a comment records why. Many
comments per detection — that is the difference from a review’s notes, which
is keyed on the detection and replaces whatever was there.
A comment is never edited: a database trigger aborts any attempt to rewrite its text, author or timestamp. It can be withdrawn, which is what a note with a mistake or somebody’s name in it needs; the audit row then carries the id and the author and never the body.
curl -X POST http://localhost:8502/api/v2/detections/comments \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date":"2026-09-03","time":"06:12:44","sci_name":"Dryobates villosus",
"body":"Call length says Downy, but the spectrogram is Hairy."}'
There is no author field, deliberately. A bearer token is not a person —
the same reason every audit row this API writes has a null user — so comments
written here are attributed to api. A name the caller supplies would be a name
the caller chose; a script with something to say about who is speaking says it
in the body. Comments written from the web UI carry the signed-in username.
curl "http://localhost:8502/api/v2/detections/comments?date=2026-09-03&time=06:12:44&sci_name=Dryobates%20villosus" \
-H "Authorization: Bearer $BNB_API_TOKEN"
Batches
One round trip instead of forty, for the case that actually comes up: triaging a night’s false positives.
curl -X POST http://localhost:8502/api/v2/detections/batch \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"op":"review","status":"rejected","detections":[
{"date":"2026-09-03","time":"02:14:07","sci_name":"Strix aluco"},
{"date":"2026-09-03","time":"02:19:51","sci_name":"Strix aluco"}
]}'
{
"op": "review",
"requested": 2,
"applied": 2,
"failed": 0,
"results": [
{ "detection": "2026-09-03 02:14:07 Strix aluco", "applied": true },
{ "detection": "2026-09-03 02:19:51 Strix aluco", "applied": true }
]
}
op is one of review, lock, unlock, delete, and applies to every
detection in the list. status and notes belong to review and are refused
with any other op: a caller who sent {"op":"delete","status":"confirmed"}
meant something by both fields, and silently dropping one would not say which.
At most 500
detections per request; a longer list is refused before anything is written
rather than quietly truncated.
Read
failed, not just the status code. The response is200whenever the request was well-formed, even if every item failed. A key that matches nothing does not stop the batch — a client working from a list a few seconds stale would otherwise have forty good deletions refused because three rows had already gone — so each detection gets its own entry inresults, andappliedandfailedare at the top level so you need not walk the array.
This is not a transaction. Each detection takes the same path as the single-detection endpoint, which is what keeps the SQLite store and the analytics copy in step; a shared transaction would mean a second implementation of “delete a detection” and is not worth that. If the process dies mid-batch, the detections already applied stay applied.
Every detection actually changed is written to the audit log individually, under the same action name a single-detection call uses — so “what happened to that recording?” is still answerable afterwards. A full batch therefore writes 500 rows, which is exactly what the admin audit view shows in one page before it says “Showing the most recent 500 matches”. That, and the cost of the writes themselves, is what the limit bounds.
Settings
GET /api/v2/settings returns every persisted setting, plus redacted (the
keys whose value was withheld) and writable_keys (what PUT will accept):
curl http://localhost:8502/api/v2/settings \
-H "Authorization: Bearer $BNB_API_TOKEN"
{
"settings": { "confidence_threshold": "0.7", "email_smtp_pass": "***REDACTED***" },
"redacted": ["email_smtp_pass"],
"writable_keys": ["alsa_device", "rtsp_url", "…"]
}
Credentials are removed two ways: by key name (email_smtp_pass,
birdweather_token) and by value shape, which catches the credential inside a
URL — apprise_url does not look like a secret and routinely carries one.
A withheld value is replaced rather than omitted, so “you may not read this”
stays distinguishable from “this was never configured”.
The by-shape rules are the support bundle’s, and they go by scheme: an RTSP
URL loses only its password (rtsp://cam:***REDACTED***@camera.local/stream),
an http(s) URL keeps its host and loses its path, because a heartbeat ping, an
Apprise endpoint and a webhook all carry their credential there
(https://hc-ping.com/***REDACTED***), and an Apprise-style notification URL
keeps only its scheme (ntfy://***REDACTED***), because those put tokens where
a URL puts its host. A value holding several URLs has each one redacted. Treat a
by-shape redaction as “the host, roughly”, not as a value you can edit and send
back.
PUT is a partial update; send only the keys you mean to change. Values may be
strings, numbers or booleans, and each goes through the same normalisation the
settings page uses, so "51,5" is stored as 51.5:
curl -X PUT http://localhost:8502/api/v2/settings \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"confidence_threshold": 0.75, "station_name": "Back Garden"}'
{ "updated": 2, "keys": ["confidence_threshold", "station_name"] }
A key already holding the value you sent is not rewritten and is not counted in
updated. Two things are refused with 400 rather than accepted quietly:
- An unknown key. A misspelled
confidence_tresholdanswering200would tell you a change had landed when none had. - The literal
***REDACTED***. It is whatGEThands back for a secret, so a client that reads the whole object, edits one field and writes it back would otherwise overwrite the station’s real SMTP password with the placeholder.
Settings that the running process reads at startup need a restart to take
effect, the same as when they are changed from /admin/settings.
Restarting
curl -X POST http://localhost:8502/api/v2/control/restart \
-H "Authorization: Bearer $BNB_API_TOKEN"
The process sends itself SIGTERM after a short delay — long enough for the
response to reach you — and systemd’s Restart=always starts a fresh instance.
Outside systemd — a bare cargo run, a container without an init — nothing
would bring the station back, so the endpoint answers 503 and signals nothing
rather than reporting a restart that would in fact be a shutdown.
Restarting one source
Restarting the station to recover a single wedged RTSP camera takes every other microphone down with it, and loses the audio in flight. This restarts one:
curl -X POST http://localhost:8502/api/v2/control/restart-source \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"source_id": "src_rtsp_1"}'
{
"restart_requested": true,
"source": "src_rtsp_1",
"state": "stalled",
"already_pending": false,
"note": "the supervisor stops and restarts this source on its next tick; every other source keeps recording"
}
202, not 200: nothing has restarted yet when the response is written. The
capture supervisor drains the request on its next reconcile tick, a couple of
seconds later. Asking twice before that happens still restarts the source once —
the second call answers "already_pending": true.
The restart goes through the supervisor’s own start path, so the recording
schedule and the source’s quiet window still hold. A source that is paused
when the request arrives stays paused, and the note says so rather than
reporting a restart that will not happen.
This recovers a wedged source; it does not reload that source’s settings.
The supervisor builds each source’s capture configuration once, when the service
starts, and a restarted process comes back with that same configuration. Editing
a source on /admin/audio still needs a service restart to take effect.
source_id is the supervisor’s label for the source, which is the
audio_sources row id on any station whose sources are managed from
/admin/audio. An unknown label answers 404 and lists the labels that do
exist; a process with no capture supervisor at all answers 503.
The same action is a Restart button beside each source on /admin/audio.
To read the labels — and to see whether the restart took — ask for the capture status. It is bearer-gated rather than public, because a station’s source labels and per-source fault history are operational detail about someone’s home:
curl http://localhost:8502/api/v2/system/capture \
-H "Authorization: Bearer $BNB_API_TOKEN"
{
"supervised": true,
"published_unix": 1789000000,
"sources": [
{
"label": "src_rtsp_1",
"state": "connected",
"uptime_secs": 512,
"last_audio_age_secs": 1,
"restart_attempts": 0,
"restarts_last_hour": 1,
"flapping": false,
"next_retry_in_secs": null,
"uptime_24h": ["up", "up", "…"]
}
]
}
state is one of connected, stalled, backing_off, paused;
uptime_24h is 48 half-hour cells, oldest first, each up, down or out
(no data — before the source was first seen, or intentionally paused).
supervised is false, with an empty list, when nothing in this process is
supervising capture: web-only mode, or tooling. That is an answer, not an error.
Restarts an operator asked for are deliberately not counted in
restarts_last_hour, and so never raise flapping. That number exists to spot
a source that cannot stay up on its own; clicking Restart three times while
debugging a camera is not that.
The database overview
GET /api/v2/system/database/overview answers with birdnet-go’s fields for
its SQLite engine: engine, status, location, size_bytes (the main
file; the WAL is sqlite.wal_size_bytes), total_detections,
total_tables, sqlite (version, journal mode, page size, free pages, cache
size, integrity_check, last_vacuum_at), tables (each with row_count,
size_bytes counting its indexes, and usage_pct), performance and
detection_rate_24h (detections in each of the last 24 UTC hours that had
any). It differs from upstream where upstream would report a number this
station did not measure:
integrity_checkis the last recorded check —ok,failedorunchecked— where upstream always saysok.wal_checkpoints,busy_timeoutsand everyperformancefigure exceptwrite_latency_avg_msarenull. Nothing here counts them, and upstream’s checkpoint count comes from running a checkpoint, which aGEThere does not.write_latency_avg_msis the mean detection write since the service started.- Table sizes come from reading every page of the file, so they are measured
at most every 15 minutes;
tables_measured_at(Unix seconds) says when.
A database that cannot be read answers 200 with status: "disconnected" and
empty lists, as upstream does.
Scheduled maintenance jobs
GET /api/v2/system/jobs reports every job the maintenance loop schedules —
the integrity check, the backup and VACUUM, the offsite upload, the recording
cap, the log retention pass, the summary drift check and the session prune —
with when each last completed, whether it passed, and whether it is due now.
Bearer-gated, for the same reason as /system/capture: when a station’s
backups run, and whether they are failing, is operational detail about
someone’s home.
curl http://localhost:8502/api/v2/system/jobs \
-H "Authorization: Bearer $BNB_API_TOKEN"
{
"now_unix": 1789000000,
"jobs": [
{
"job": "integrity_check",
"title": "Database integrity check",
"interval_secs": 86400,
"last_run_unix": 1788950000,
"next_due_unix": 1789036400,
"ok": true,
"reports_verdict": true,
"due": false,
"due_reason": null
},
{
"job": "backup_vacuum",
"title": "Backup and VACUUM",
"interval_secs": 604800,
"last_run_unix": null,
"next_due_unix": null,
"ok": null,
"reports_verdict": true,
"due": true,
"due_reason": "never_run"
}
]
}
The list is every job, not every job that has run. maintenance_runs
holds a row per job that has completed at least once, so an endpoint built by
listing rows would answer with a short, clean list that omitted exactly the
jobs worth asking about — a station whose backup has never run once would
simply not mention it. The second entry above is that case.
ok is tri-state; do not collapse it. null means either never run or
this job has no pass/fail to report, and reports_verdict separates those
from a recorded false. A session prune that succeeded and an integrity check
that failed both have falsy verdicts and mean opposite things.
due_reason is never_run, interval_elapsed, or clock_went_backwards —
the last meaning the recorded run is in the future, which happens on a Pi with
no real-time clock that boots at the epoch and then has NTP land. The job runs
and re-anchors its schedule rather than waiting years for real time to catch
up.
The backup’s interval_secs follows BACKUP_SCHEDULE, the one cadence an
operator sets, so a station on daily is reported on a daily cadence rather
than told it is not due for another six days. Every other job’s interval is
fixed — including the space reclaim, which stays weekly whatever the backup is
set to.
due is the scheduler’s own rule, shared with the loop that runs the jobs
rather than re-implemented here. One thing it cannot see is that loop’s
in-process floor, which stops a station with a full disk re-running a weekly
VACUUM every half hour; it only ever delays a job within one process lifetime,
so a job reported as due may already have run since its last recorded
completion. That is the honest answer from persisted state, which is the only
state you can inspect.
The audit log
Every change is written to the audit log as
detection.review / detection.lock / detection.unlock / detection.delete
/ settings.update / system.restart / audio.source.restart, with no user
and via=api in the metadata — a batch writes one such row per detection it changed, under the same
names — a token is not a person, and the log says so rather than inventing
one. A settings change records the key names only: an entry reading
birdweather_token=… would put a credential on the page that renders the log.
The restart entry is written before the decision, so a refused restart is
recorded too.
Cross-origin calls
These endpoints are exempt from the dashboard’s same-origin (CSRF) check,
because a cross-site form cannot set an Authorization header — that is the
whole premise of the check, so it has nothing to protect here. The exemption is
scoped to these paths; every other write in the product still has it.
Design tokens
The web UI’s entire visual language — colour, type, spacing, elevation, corner
radii, motion — is expressed as a small set of CSS custom properties
(“design tokens”) defined once at the top of
crates/birdnet-web/static/css/app.css.
Components read tokens; a colour or a pixel pad written into a rule is a defect
unless this page says why it is allowed (see What stays literal).
This page is the reference for what each token means and what value it carries
in each theme.
Two principles govern them:
- Colours are OKLCH. OKLCH is perceptually uniform, so
a hue stays the same “colour” as its lightness changes, and light/dark themes
can be derived by moving lightness rather than re-picking hex values. The
format is
oklch(L C H)— lightness %, chroma, hue degrees — with an optional/ alpha. Every colour in the stylesheet, the categorical chart palette included, is written that way, so a colour-blind-safety check runs on the exact values that ship (see Chart colours). - The token set grows by decision, not by accident. New UI is built by composing existing tokens into new classes. Adding a token is a deliberate, reviewed design decision (see Adding or changing a token), and the gates below refuse a raw colour, shadow, radius, duration, curve, layer or on-ladder gap where a token exists.
At a glance
The stylesheet’s light block declares
241 tokens, and the dark block overrides 90 of them. Those two numbers are checked againstapp.css by the test below, so they cannot go stale; every other number on this
page that describes the present is read from the stylesheet the same way.
| What | Enforced by |
|---|---|
| Every token declared here, with the value it has in each theme | tests/design_tokens_doc_matches_the_stylesheet.rs |
Every var(--x) names a token that exists | tests/css_variables_are_defined.rs |
| Every token that flips between themes is forced to a light value on paper | tests/print_forces_every_theme_flipped_token.rs |
| No raw colour literal outside the token blocks, and no raw weight, hairline or bar radius where a token exists | tests/design_tokens_are_the_only_literals.rs |
| Every colour token has a value in both themes, or is named as theme-invariant | tests/design_tokens_are_the_only_literals.rs |
| The contrast ratios the tokens claim, measured in a browser in both themes | tools/visual-qa/tokens.mjs |
| A token refactor changes no element’s computed style | tools/visual-qa/style-snapshot.mjs |
| No raw shadow, radius, duration, curve, layer or on-ladder gap outside a token definition | tests/design_tokens_are_the_only_literals.rs (rule 5) |
| The sequential, diverging and data-ink tokens, graded in both themes | tools/visual-qa/chart-ramps.mjs |
| The page washes, the translucent nav, surface order and the focus ring, graded | tools/visual-qa/surface-layers.mjs |
The generated tables, every hand-written row’s values and both counts equal app.css | tools/visual-qa/token-doc.mjs --check |
Every token, drawn
Both pictures are rendered from the stylesheet the station serves, so they are the
tokens and not a drawing of them. tools/visual-qa/token-swatches.mjs regenerates
them; a token added to app.css appears without the script changing.
Themes
Tokens are defined for two themes, selected by the data-theme attribute on the
:root element (set early by static/theme-guard.js to avoid a flash):
| Theme | Selector | Notes |
|---|---|---|
| Light | :root, :root[data-theme="light"] | The default. |
| Dark | :root[data-theme="dark"] | Overrides the colour + shadow tokens only; structural tokens (radii, type, motion, layers, spacing) are shared. |
Because components only ever read tokens, switching data-theme reskins the
whole UI with no per-component dark-mode CSS. A token that differs between the
two blocks must also be listed in static/css/print.css, which forces the light
values on paper (print_forces_every_theme_flipped_token.rs enforces it).
Colour — surfaces & lines
The greys that build the page: backgrounds, card surfaces, and dividers.
| Token | Light | Dark | Use |
|---|---|---|---|
--bg | oklch(98.5% 0.004 80) | oklch(12% 0.008 250) | Page background. |
--bg-2 | oklch(96.5% 0.005 80) | oklch(15% 0.008 250) | Secondary background / inset wells. |
--surface | oklch(100% 0 0) | oklch(18% 0.010 250) | Card / panel surface. |
--surface-2 | oklch(97.5% 0.004 80) | oklch(22% 0.010 250) | Nested surface, hover fill. |
--border | oklch(90% 0.006 80) | oklch(28% 0.014 250) | Default border. |
--border-2 | oklch(82% 0.008 80) | oklch(40% 0.018 250) | Stronger border (inputs, buttons). |
--hairline | oklch(94% 0.005 80) | oklch(24% 0.012 250) | Faint divider between list rows. |
Colour — text
A four-step text hierarchy, brightest (most important) to faintest.
| Token | Light | Dark | Use |
|---|---|---|---|
--fg | oklch(22% 0.008 70) | oklch(97% 0.004 240) | Primary text. |
--fg-2 | oklch(40% 0.008 70) | oklch(80% 0.008 240) | Secondary text. |
--fg-3 | oklch(49% 0.008 70) | oklch(64% 0.010 240) | Muted / metadata. |
--fg-4 | oklch(55% 0.012 70) | oklch(62% 0.014 240) | Faint / disabled. |
Colour — semantic hues
Four hue families carry meaning across the app, and the legacy names --success,
--warning, --danger (below) say which: success is moss, warning is
dawn, danger is rare, and information is its own cool blue so that “for
your information” never reads as either “all is well” or “look at this”. Each has
a base, a -soft tint (for fills/badges) and an -ink (for text/icons on a soft
fill; --rare is dark enough to be its own ink).
| Token | Light | Dark | Meaning |
|---|---|---|---|
--moss | oklch(52% 0.09 150) | oklch(78% 0.18 150) | Primary / healthy / success — the “green” of a thriving yard. |
--moss-soft | oklch(92% 0.04 150) | oklch(26% 0.10 150) | Soft green fill (a positive pill, the “live” tint, a very-sure confidence pill). |
--moss-ink | oklch(35% 0.09 150) | oklch(90% 0.16 150) | Green text/ink on a soft fill. |
--dawn | oklch(68% 0.12 60) | oklch(82% 0.18 65) | Morning / dawn chorus / warning — amber. A fill, never text. |
--dawn-soft | oklch(94% 0.05 65) | oklch(28% 0.10 60) | Soft amber fill (a warning pill, a borderline-confidence pill). |
--dawn-ink | oklch(42% 0.12 55) | oklch(92% 0.16 60) | Amber text/ink. |
--rare | oklch(50% 0.16 28) | oklch(74% 0.20 25) | Rare bird / alert / danger — red. |
--rare-soft | oklch(94% 0.05 28) | oklch(28% 0.12 25) | Soft red fill (rare-bird and alert badges). Never a confidence. |
--info | oklch(48% 0.10 245) | oklch(76% 0.11 245) | Information — a cool blue. A fill or an icon; the text on it is --info-ink. |
--info-soft | oklch(94% 0.03 245) | oklch(27% 0.06 245) | Soft blue fill (an informational pill or callout). |
--info-ink | oklch(36% 0.10 245) | oklch(88% 0.07 245) | Blue text/ink on a soft fill. |
Colour — confidence tiers
How sure the identifier was, painted the same way on every surface that shows it
(the Today feed, species, recordings, the review and quarantine queues, the share
page). One rule picks the tier — crates/birdnet-web/src/routes/pages/confidence.rs
— and these tokens only say what each tier looks like.
| Tier | Whole percent | Reads as |
|---|---|---|
| high | 90 and up | very sure |
| mid | from the borderline edge to 89 | sure |
| low | below the borderline edge | borderline |
The borderline edge follows the station’s own confidence cut-off: the cut-off
plus ten points, never above 90. At a 70 % cut-off, 80–89 is mid and 70–79 is low;
a strict station (85 % or more) has no mid tier. The scale never uses --rare:
a detection the station accepted at 72 % is not a fault.
| Token | Light | Dark | Use |
|---|---|---|---|
--conf-high | var(--moss) | oklch(70% 0.12 150) | Bar fill for a very sure detection. |
--conf-high-soft | var(--moss-soft) | Pill background, high. | |
--conf-high-ink | var(--moss-ink) | Pill text, high. | |
--conf-mid | oklch(58% 0.025 150) | oklch(68% 0.025 150) | Bar fill for a sure detection. |
--conf-mid-soft | var(--surface-2) | Pill background, mid. | |
--conf-mid-ink | var(--fg-2) | Pill text, mid. | |
--conf-low | oklch(62% 0.125 74) | oklch(80% 0.14 75) | Bar fill for a borderline detection. |
--conf-low-soft | var(--dawn-soft) | Pill background, low. | |
--conf-low-ink | var(--dawn-ink) | Pill text, low. |
Colour — special surfaces
| Token | Light | Dark | Use |
|---|---|---|---|
--paper | oklch(96% 0.012 75) | oklch(18% 0.010 250) | Warm “paper” surface for editorial recaps (Reports). |
--night | oklch(28% 0.04 270) | oklch(8% 0.020 250) | Deep night surface (e.g. live spectrogram backdrop). |
--card-top | var(--surface) | oklch(20% 0.010 250) | The top of a dark card’s gradient. Light cards are flat, so light only names it. |
--card-bottom | var(--surface) | oklch(17% 0.010 250) | The bottom of a dark card’s gradient. |
--sheen | transparent | oklch(100% 0 0 / .04) | A faint light along a dark card’s upper edge. |
--glow | transparent | oklch(60% 0.04 240 / .10) | The soft halo behind display type on a dark page. |
Colour — text on a filled surface
| Token | Light | Dark | Use |
|---|---|---|---|
--on-moss | oklch(100% 0 0) | oklch(18% 0.02 150) | Text on a --moss fill (primary buttons). |
--on-fill | #fff | oklch(15% 0.02 150) | Text on any saturated fill (badges, hovered amber buttons). |
--sp-ink | oklch(10% 0 0) | oklch(98% 0 0) | What a species colour is mixed toward for its own text, so the hue stays and the contrast holds. |
--accent-rgb | 60, 120, 80 | 120, 200, 150 | The accent as an r, g, b triple, for rgba() tints. |
Two inks are fixed because the fill they sit on does not change between themes:
| Token | Value | Use |
|---|---|---|
--on-dawn | oklch(20% 0.04 60) | Ink on a --dawn fill (the amber button): amber is a bright fill in both themes, so its ink is dark in both. |
--switch-thumb | oklch(100% 0 0) | A toggle’s thumb, white on its track in both themes. |
Elevation — shadows
Three elevations (and the rest of the family under More scales).
Each layers a tight contact shadow, a long soft one and a hairline 0 0 0 0.5px
ring so a card reads as lifted on both themes. The shadows are mixed from
--shadow-tint (a warm brown on paper, a cool near-black on night), never from
pure black, so a lifted card throws a shadow the colour of the page it sits on. The
dark theme deepens them and switches the ring to a faint light edge, and the large
one carries a one-pixel light along its top edge.
| Token | Light | Dark | Use |
|---|---|---|---|
--shadow-sm | 0 1px 2px color-mix(in oklab, var(--shadow-tint) 7%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 8%, transparent) | 0 1px 2px color-mix(in oklab, var(--shadow-tint) 55%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .06) | Subtle lift (cards, chips, inputs). |
--shadow-md | 0 2px 4px color-mix(in oklab, var(--shadow-tint) 5%, transparent), 0 8px 20px -4px color-mix(in oklab, var(--shadow-tint) 11%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 8%, transparent) | 0 2px 6px color-mix(in oklab, var(--shadow-tint) 35%, transparent), 0 10px 28px -4px color-mix(in oklab, var(--shadow-tint) 70%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .08) | Popovers, hovered cards. |
--shadow-lg | 0 4px 8px color-mix(in oklab, var(--shadow-tint) 5%, transparent), 0 20px 44px -8px color-mix(in oklab, var(--shadow-tint) 19%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 9%, transparent) | 0 4px 10px color-mix(in oklab, var(--shadow-tint) 35%, transparent), 0 24px 56px -8px color-mix(in oklab, var(--shadow-tint) 80%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .09), inset 0 1px 0 oklch(100% 0 0 / .05) | Modals, the floating now-playing bar. |
Focus & scrim
| Token | Value | Use |
|---|---|---|
--ring | 0 0 0 2px var(--surface), 0 0 0 4px var(--moss) | The keyboard focus ring for a field: a --surface gap, then --moss. One token so a high-contrast theme changes it once. |
--ring-soft | 0 0 0 3px color-mix(in oklab, var(--moss) 22%, transparent) | The softer halo a pointer-focused field keeps. |
--scrim | oklch(0% 0 0 / .32) | The dimming layer behind a dialog, a drawer or a sheet. Five rules had three different opacities (22, 32 and 38 %) before this existed. |
Radii
| Token | Value | Use |
|---|---|---|
--r-2xs | 3px | The ends of a bar, a tile’s chip, a small mark. |
--r-xs | 4px | Checkboxes, tiny chips. |
--r-sm | 6px | Buttons, inputs. |
--r-md | 10px | Cards, rows. |
--r-lg | 14px | Large cards, the head player. |
--r-xl | 20px | Hero panels. |
--r-pill | 999px | A fully rounded pill (chips, badges, round buttons). |
Borders
| Token | Value | Use |
|---|---|---|
--bw | 0.5px | The hairline: every card edge, row divider and control edge. High contrast (the Display preference, or the OS asking for more) changes this one value to --bw-strong, and every line in the product thickens at once. |
--bw-strong | 1px | A line that must read as a line: what --bw becomes under high contrast. |
Spacing & density
Padding is derived from a single --density multiplier, so the operator’s
density preference (Settings → General → Display) rescales the whole UI by changing one
value rather than re-spacing every component.
| Token | Value | Use |
|---|---|---|
--density | 1 | Density multiplier (compact / comfy / regular set this). |
--pad-1 | calc(8px * var(--density)) | Tight padding / small gaps. |
--pad-2 | calc(12px * var(--density)) | Default control padding. |
--pad-3 | calc(18px * var(--density)) | Card padding; the gap between blocks on a page. |
--pad-4 | calc(28px * var(--density)) | Section padding. |
--pad-* is the rhythm between blocks and inside cards, and it moves with
density. The small gaps within a component — an icon beside its label, the
cells of a calendar, the gap under an eyebrow — are optical adjustments to that
component’s own size and must not move with density, so they have their own,
fixed ladder. --space-N is N pixels. It is the set of values the stylesheet
used most, not a modular scale. The odd in-between gaps (3, 5, 7, 9, 11, 13 px)
are still written as literals where a component’s own optical balance depends
on them; a new rule should use the nearer rung.
| Token | Value | Use |
|---|---|---|
--space-2 | 2px | The gap between a number and the bar under it. |
--space-4 | 4px | Tight pairs: a label and its value. |
--space-6 | 6px | An icon and its label; chips in a row. |
--space-8 | 8px | Rows of controls; a heading and its first line. |
--space-10 | 10px | The default gap inside a list row or a card header. |
--space-12 | 12px | Between a card’s header and its body. |
--space-14 | 14px | Between small cards in a grid. |
--space-16 | 16px | Between groups inside a card. |
--space-18 | 18px | Between a detail page’s blocks: a card, then its actions or the next card. |
--space-20 | 20px | Between a page’s blocks on a phone. |
--space-24 | 24px | Between a page’s blocks on a desktop. |
--space-32 | 32px | Between a page’s major parts: an identity strip and what follows it. |
Typography — families
Self-hosted font stacks (no external font CDNs) with broad CJK / Devanagari fallbacks so localized common names render.
| Token | Value | Use |
|---|---|---|
--font-display | "Instrument Serif", "Source Serif 4", Georgia, "Noto Serif CJK SC", "Noto Serif CJK JP", serif | Editorial headlines, card titles and statistic numerals. |
--font-ui | "Inter Tight", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Devanagari", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif | Body and UI text. |
--font-mono | "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace | Times, codes, numeric tables. |
Typography — sizes
The stylesheet used to carry dozens of font-size values, half-pixel steps
included. They are a scale now. The first six steps are UI text; the rest are the
serif display steps. Sizes at or below 16 px were snapped to the nearest step
(never by more than 0.8 px); larger one-off sizes (an icon glyph, a gauge
numeral, a 404 code) stay literal and are listed under
What stays literal.
| Token | Value | Use |
|---|---|---|
--fs-2xs | 10px | Axis ticks, banding codes, the smallest mono tags. |
--fs-xs | 11px | Eyebrows, table heads, captions, chips. |
--fs-sm | 12px | Hints, meta lines, secondary controls. |
--fs-md | 13px | Buttons, form controls, table cells. |
--fs-base | 14px | Body text (set on <body>). |
--fs-lg | 16px | Lede paragraphs and sub-headings. 16 px also keeps iOS from zooming a focused field. |
--fs-lead | 20px | A headline sentence under a title; a small numeral. |
--fs-title | 22px | A card or section title, in the serif. |
--fs-h3 | 24px | A sub-section title, or a numeral between a title and a statistic. |
--fs-stat | 32px | A statistic’s numeral, in the serif. |
--fs-stat-lg | 44px | The large numeral of a four-up stat tile. |
--fs-h1 | clamp(34px, 3.2vw + 22px, 56px) | A page title. Fluid, so a phone gets a title that fits rather than one that wraps four times. |
--fs-hero | clamp(38px, 4.2vw + 22px, 72px) | An editorial headline: Today, the species name, the weekly and yearly recaps. |
Typography — weights and tracking
The stylesheet writes font-weight and letter-spacing as these, never as a
number. Inter Tight ships 400, 500 and 600; 700 is the browser’s synthetic bold
and is asked for in a handful of places only.
| Token | Value | Use |
|---|---|---|
--fw-regular | 400 | Body text; the serif display faces have one weight. |
--fw-medium | 500 | UI labels, buttons, table heads, names. |
--fw-semibold | 600 | Emphasis inside a sentence; a selected tab. |
--fw-bold | 700 | Rare: a count in a badge. |
--track-tight | -0.01em | UI headings. |
--track-display | -0.02em | The serif at display sizes, where its default spacing opens up. |
--track-caps-sm | 0.08em | Small-caps table heads and tile labels. |
--track-caps | 0.10em | Eyebrows. |
Motion
Three durations and two curves. prefers-reduced-motion (and the operator’s
Motion setting) flatten every transition and animation at once, so none of these
needs its own opt-out.
| Token | Value | Use |
|---|---|---|
--dur-fast | .12s | Hover and press feedback (background, border, colour). |
--dur-base | .2s | Disclosures, popovers, dialogs and drawers appearing. |
--dur-slow | .32s | Something moving a distance: a progress bar, the floating dock, a row rising in. |
--ease-out | cubic-bezier(.2, .7, .2, 1) | Things arriving. |
--ease-in-out | cubic-bezier(.4, 0, .2, 1) | Things that loop or travel and settle (the skeleton shimmer). |
Layers
Only the stacking layers that must agree across components are tokens. A
z-index of 0 or 1 inside a component’s own stacking context is local and stays
a literal.
| Token | Value | Use |
|---|---|---|
--z-raised | 5 | A sticky bar inside a page (the Today search row). |
--z-sticky | 50 | The top navigation and the floating recordings dock. |
--z-tabbar | 55 | The phone’s bottom tab bar (above the dock, below toasts). |
--z-toast | 60 | The toast region. |
--z-skip | 100 | The skip-to-content link, above everything when focused. |
Chart colours
A chart does not borrow UI colours. The brand greens, ambers and reds mean
accepted, attention and alert elsewhere on the page, and the dark theme’s
--moss is a 78 % lime chosen to read as text on near-black, which on a bar or a
band glows beside the grey text it labels. Charts have their own set, defined
once for each theme and graded by arithmetic, not by eye
(tests/the_chart_palette_can_be_told_apart.rs, and tools/visual-qa/tokens.mjs
on the colours a real page paints).
Categorical (--chart-1 … --chart-8): which species, which source. Assign
them in this order and never cycle: a ninth series is --chart-other, a neutral,
and means “everything else”. The order is part of the palette, not a preference.
It is the best of the 20 orderings (of the 5 040 that keep green first) that clear
the gates in both themes: it opens with the brand colour and keeps every
neighbouring pair apart for a reader who cannot tell red from green
(OKLab ΔE of at least 8 under the Machado protan and deutan simulations; the
worst pair measures 12.2) and for a reader who can (at least 15; the worst is
17.4). The dark column is the same eight hues stepped to
the dark band (OKLCH lightness 0.48–0.67), not an automatic flip, so no mark
needs a filter to show.
Amber (--chart-7) is 2.35:1 against a white card. Darkened to 3:1 it is brown, so
it stays amber and every chart that draws it names the band in a legend, in the
band’s tooltip and in a table; that exemption is listed by slot in the test, with
the reason.
| Token | Light | Dark | Use |
|---|---|---|---|
--chart-1 | oklch(56% 0.12 150) | oklch(64% 0.12 150) | Series 1, and the colour of any chart with one series: moss. |
--chart-2 | oklch(58% 0.14 250) | oklch(62% 0.13 250) | Series 2: blue. |
--chart-3 | oklch(60% 0.18 15) | oklch(62% 0.16 15) | Series 3: rose. |
--chart-4 | oklch(54% 0.16 298) | oklch(60% 0.15 298) | Series 4: violet. |
--chart-5 | oklch(64% 0.16 42) | oklch(65% 0.14 42) | Series 5: orange. |
--chart-6 | oklch(62% 0.105 196) | oklch(64% 0.109 196) | Series 6: teal. |
--chart-7 | oklch(74% 0.15 78) | oklch(66% 0.12 78) | Series 7: amber (see above). |
--chart-8 | oklch(62% 0.16 355) | oklch(64% 0.14 355) | Series 8: magenta. |
--chart-other | oklch(62% 0.012 80) | oklch(62% 0.014 250) | “Everything else”: a neutral, never a ninth hue. |
Sequential (--heat-lo, --heat-mid, --heat-hi): how much. One hue family
(sand, orange, brick in light; brown, amber, gold in dark) with lightness that
only goes one way, so a cell is darker the more it holds on paper and brighter the
more it holds on night, and the ramp means the same thing in both. The lowest step
is held at least 8 ΔE from an empty cell (--surface-2), or a cell with one
detection reads as none, and the highest step is held 3:1 against the card.
| Token | Light | Dark | Use |
|---|---|---|---|
--heat-lo | oklch(90% 0.07 90) | oklch(36% 0.06 70) | The least a cell can hold and still be drawn. |
--heat-mid | oklch(74% 0.15 65) | oklch(62% 0.13 65) | Halfway. |
--heat-hi | oklch(46% 0.15 32) | oklch(86% 0.14 92) | The most. |
Every chart reads these and nothing else: tests/the_chart_palette_can_be_told_apart.rs
scans the chart renderers for a mark painted in --moss, --dawn, --rare, --info or
--accent-rgb, or in a colour literal. The exceptions are named in the test, with a
reason: the sun and moon glyphs on the day strip and the polar clock are the sun and the
moon. A heat cell interpolates --heat-lo → --heat-mid → --heat-hi in OKLab and is
drawn at full strength with no fill-opacity, so what is painted is what was graded. The
one place that keeps its own ramp is the History calendar, which prints a day number on
each cell and so uses a lighter, capped tint of --moss that keeps that number at 4.5:1.
Chart ramps and data ink
The categorical set above says which. These say how much, which side, and
what a chart draws that is not data. A chart renderer reads these and nothing else
(no --moss, no literal), so one change here retunes every chart.
Which ramp for which question
| The data says | Use | Why |
|---|---|---|
| Identity: which species, which source | --chart-1 … --chart-8, then --chart-other | Fixed order, never cycled. |
| Magnitude: how many, how loud | --seq-1 … --seq-7 | One hue family, lightness moves one way. |
| Magnitude on a cell that prints its own number | --seq-moss-1 … --seq-moss-7 | Pale enough that the number keeps 4.5:1. |
| Polarity: above or below a baseline | --div-neg-3 … --div-pos-3 | Two hues and a neutral middle. |
| State: good, watch, bad | --moss, --dawn, --rare, always with a word or an icon | Reserved meaning; never a series. |
Interpolate between two steps in OKLab (color-mix(in oklab, var(--seq-5) 40%, var(--seq-4))),
never in sRGB or OKLCH, which bend through grey or swing the hue. A ramp is
graded by arithmetic, not by eye: tools/visual-qa/chart-ramps.mjs reads the
shipped values and fails on a step that is not monotone in lightness, a step
that is not 7 to 13 dE from its neighbour, a lowest step within 8 dE of an empty
cell, a highest step under 3:1 on its card, the two diverging poles within 15 dE
of each other under either colour-blind simulation, or a data-ink colour under its
stated contrast.
How a renderer reads them
An SVG mark takes its colour from a class (the stylesheet, not a presentation
attribute, so the theme can change it) or from a custom property set on the element
through data-style. Never a literal, never --moss.
.my-grid { stroke: var(--chart-grid); stroke-width: 1; }
.my-axis { stroke: var(--chart-axis); stroke-width: 1; }
.my-label { fill: var(--chart-label); font-size: var(--fs-2xs); }
.my-line { stroke: var(--chart-1); stroke-width: var(--chart-stroke); fill: none; }
.my-bar { fill: var(--chart-2); stroke: var(--chart-gap); stroke-width: var(--chart-gap-w); }
.my-dim { opacity: var(--chart-dim); } /* the marks that are not hovered */
/* A cell a quarter of the way from step 4 to step 5, as OKLab: */
.my-cell { fill: color-mix(in oklab, var(--seq-5) 25%, var(--seq-4)); }
/* One value either side of a baseline: */
.my-up { fill: var(--div-pos-2); }
.my-down { fill: var(--div-neg-2); }
A tooltip is background: var(--chart-tip-bg); color: var(--chart-tip-fg); border: var(--bw-strong) solid var(--chart-tip-border), with --shadow-md under it.
Text on a chart is never drawn in a series colour: the mark carries the identity, the
label stays in --chart-label or --chart-annot.
Sequential (the sun ramp)
Light runs pale to brick, dark runs ember to gold: in both, the more a cell holds, the more it stands out from the card.
| Token | Light | Dark | Use |
|---|---|---|---|
--seq-1 | oklch(92% 0.07 95) | oklch(32% 0.05 60) | The least a cell can hold and still be drawn (held 8 dE from an empty cell). |
--seq-2 | oklch(85% 0.11 85) | oklch(40% 0.085 55) | Sequential sun ramp, step 2. |
--seq-3 | oklch(77% 0.145 72) | oklch(49% 0.115 55) | Step 3. |
--seq-4 | oklch(69% 0.16 58) | oklch(58% 0.13 62) | The middle of the ramp. |
--seq-5 | oklch(60% 0.165 46) | oklch(67% 0.14 72) | Step 5. |
--seq-6 | oklch(50% 0.15 36) | oklch(77% 0.145 85) | Step 6. |
--seq-7 | oklch(40% 0.12 30) | oklch(88% 0.13 98) | The most. 9.8:1 on a light card, 13:1 on a dark one. |
Sequential (the moss ramp)
| Token | Light | Dark | Use |
|---|---|---|---|
--seq-moss-1 | oklch(91% 0.055 150) | oklch(31% 0.04 150) | Moss ramp, the least. |
--seq-moss-2 | oklch(83.5% 0.085 152) | oklch(39% 0.065 152) | Step 2. |
--seq-moss-3 | oklch(75.5% 0.105 152) | oklch(48% 0.09 152) | Step 3. |
--seq-moss-4 | oklch(67% 0.118 151) | oklch(57% 0.11 151) | Step 4. |
--seq-moss-5 | oklch(58% 0.118 150) | oklch(66% 0.125 150) | Step 5. |
--seq-moss-6 | oklch(49% 0.102 150) | oklch(76% 0.14 150) | Step 6. |
--seq-moss-7 | oklch(40% 0.085 150) | oklch(87% 0.13 150) | The most. |
Diverging
| Token | Light | Dark | Use |
|---|---|---|---|
--div-neg-3 | oklch(42% 0.105 250) | oklch(80% 0.11 240) | Far below the baseline (strong blue). |
--div-neg-2 | oklch(62% 0.11 248) | oklch(60% 0.12 248) | Below. |
--div-neg-1 | oklch(83% 0.055 245) | oklch(40% 0.07 250) | Slightly below. |
--div-mid | oklch(95% 0.004 80) | oklch(26% 0.008 250) | The baseline: a neutral grey, never a hue. |
--div-pos-1 | oklch(85% 0.08 62) | oklch(40% 0.08 52) | Slightly above. |
--div-pos-2 | oklch(70% 0.15 50) | oklch(62% 0.15 48) | Above. |
--div-pos-3 | oklch(50% 0.15 36) | oklch(82% 0.115 62) | Far above the baseline (strong brick). |
Data ink and mark anatomy
| Token | Light | Dark | Use |
|---|---|---|---|
--chart-grid | oklch(93% 0.005 80) | oklch(26% 0.012 250) | A gridline: 1.2:1 against the card on purpose, a guide and not information. |
--chart-grid-strong | oklch(87% 0.006 80) | oklch(32% 0.014 250) | The gridline that marks a boundary (midnight, the zero line of a diverging chart). |
--chart-axis | oklch(64% 0.010 70) | oklch(58% 0.014 250) | An axis line and its ticks: 3:1 against the card. |
--chart-tip-bg | oklch(24% 0.010 70) | oklch(30% 0.014 250) | A chart tooltip: a dark card on the light theme, a lifted one on dark. |
--chart-tip-fg | oklch(98% 0.004 80) | oklch(97% 0.004 240) | Tooltip text, 13:1 or better on the tooltip. |
--chart-tip-border | oklch(34% 0.010 70) | oklch(42% 0.016 250) | Tooltip edge, so it never merges with the card behind it. |
The rest of the ink is the same in both themes, or reads a token that already flips:
| Token | Value | Use |
|---|---|---|
--chart-label | var(--fg-3) | An axis label or a mark label: text, 4.5:1. |
--chart-annot | var(--fg-2) | An annotation drawn on the plot (“peak”, “now”): text, stronger than a label. |
--chart-track | var(--bg-2) | The empty part of a bar: the track a fill sits on. |
--chart-gap | var(--surface) | The colour of the gap between touching fills: the card, so a stack reads as separate marks. |
--chart-crosshair | var(--fg-3) | The hover crosshair line. |
--chart-stroke | 2px | A line mark’s width. |
--chart-gap-w | 2px | The width of the gap between touching fills. |
--chart-bar-r | 4px | The round on the data end of a bar, anchored square at the baseline. |
--chart-dim | .35 | The opacity of the marks that are not hovered. |
More scales
The scales the first sections do not cover: layered surfaces and tinted shadows,
the rest of the radius, spacing, type and motion ladders, layers, the focus ring
and density. Each table is generated from app.css by
tools/visual-qa/token-doc.mjs, so a value here is the value the stylesheet has.
Surfaces
| Token | Light | Dark | Use |
|---|---|---|---|
--surface-raised | var(--surface) | oklch(20% 0.011 250) | A card. Equal to –surface in light; one step lighter than the page in dark, where nearer is lighter. |
--surface-overlay | var(--surface) | oklch(24% 0.012 250) | A popover, a menu, a dialog: lifted again in dark. |
--wash-a | oklch(90% 0.07 150 / .40) | oklch(34% 0.09 160 / .32) | The green wash behind the top left of the page. |
--wash-b | oklch(93% 0.07 75 / .45) | oklch(30% 0.07 60 / .20) | The warm wash behind the top right. |
| Token | Value | Use |
|---|---|---|
--surface-sunken | var(--bg-2) | A well cut into a card: an inset field, a track, a code block. |
--surface-glass | color-mix(in oklab, var(--surface-overlay) 76%, transparent) | The translucent surface of a sticky bar, drawn with –glass-blur. |
--glass-blur | saturate(1.5) blur(14px) | The backdrop-filter of a glass surface. |
Shadows
A shadow is mixed from --shadow-tint, so a lifted card throws a warm shadow on
paper and a cool one on night, never a grey smudge.
| Token | Light | Dark | Use |
|---|---|---|---|
--shadow-tint | oklch(32% 0.045 65) | oklch(4% 0.03 255) | What every shadow is mixed from: warm brown on paper, cool near-black on night. Never pure black. |
--shadow-xs | 0 1px 1px color-mix(in oklab, var(--shadow-tint) 8%, transparent) | 0 1px 1px color-mix(in oklab, var(--shadow-tint) 60%, transparent) | A hairline of depth: a chip, a segmented control. |
--shadow-hover | 0 2px 4px color-mix(in oklab, var(--shadow-tint) 7%, transparent), 0 12px 28px -6px color-mix(in oklab, var(--shadow-tint) 15%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 9%, transparent) | 0 2px 4px color-mix(in oklab, var(--shadow-tint) 40%, transparent), 0 14px 32px -6px color-mix(in oklab, var(--shadow-tint) 75%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .09) | A card the pointer is on: between –shadow-md and –shadow-lg. |
--shadow-xl | 0 6px 12px color-mix(in oklab, var(--shadow-tint) 6%, transparent), 0 32px 64px -12px color-mix(in oklab, var(--shadow-tint) 24%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 9%, transparent) | 0 8px 16px color-mix(in oklab, var(--shadow-tint) 40%, transparent), 0 40px 80px -12px color-mix(in oklab, var(--shadow-tint) 80%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .09), inset 0 1px 0 oklch(100% 0 0 / .06) | A command palette, a drawer, the biggest thing on the screen. |
--shadow-press | inset 0 1px 2px color-mix(in oklab, var(--shadow-tint) 16%, transparent) | inset 0 1px 3px color-mix(in oklab, var(--shadow-tint) 70%, transparent) | A control being pressed: it sinks into the page. |
--shadow-inset | inset 0 1px 0 oklch(100% 0 0 / .7) | inset 0 1px 0 oklch(100% 0 0 / .06) | The one-pixel light along the top edge of a raised surface. |
--text-glow | none | 0 0 28px var(--glow) | The halo behind display type on a dark page; none on paper. |
--shadow-field | inset 0 1px 2px color-mix(in oklab, var(--shadow-tint) 8%, transparent) | inset 0 1px 2px color-mix(in oklab, var(--shadow-tint) 55%, transparent) | The inner shadow of a text field or a segmented track: it is cut into the page, not set on it. |
| Token | Value | Use |
|---|---|---|
--ring-danger | 0 0 0 4px color-mix(in oklab, var(--rare) 8%, transparent) | The faint red ring around a destructive dialog. |
--accent-bar | 3px | The width of the accent bar down the left edge of a banner or a toast (an inset shadow in the banner’s own colour). |
--accent-bar-sm | 2px | The same on a list row. |
--halo-w | 3px | The width of the halo round a status dot in a toast. |
--shadow-key | inset 0 1px 0 color-mix(in oklab, currentColor 20%, transparent) | A one-pixel highlight along the top of a filled button, in that button’s own text colour. |
--shadow-up | 0 -10px 28px -20px color-mix(in oklab, var(--shadow-tint) 40%, transparent) | The faint shadow cast upward by a bar fixed to the bottom of the screen. |
--shadow-glow | 0 0 0 1px color-mix(in oklab, var(--moss) 30%, transparent), 0 6px 24px -6px color-mix(in oklab, var(--moss) 38%, transparent) | A moss ring and a soft green halo: a live or selected thing that should be noticed. |
Radius
| Token | Value | Use |
|---|---|---|
--r-3xs | 2px | The smallest round: a meter, a tick. |
--r-base | 8px | A hover highlight on a list row. |
--r-2xl | 28px | A sheet. |
--r-control | var(--r-sm) | A button or a segmented control. Today the same as –r-sm. |
--r-field | var(--r-sm) | A text field or a select. |
--r-card | var(--r-lg) | A card. |
--r-popover | var(--r-md) | A menu or a tooltip. |
--r-dialog | var(--r-xl) | A dialog or a drawer. |
Edges
| Token | Value | Use |
|---|---|---|
--edge | var(--bw) solid var(--border) | A card’s edge: the hairline in –border. |
--edge-2 | var(--bw) solid var(--border-2) | A control’s edge: the hairline in –border-2. |
--edge-hair | var(--bw) solid var(--hairline) | A divider: the hairline in –hairline. |
Spacing
| Token | Value | Use |
|---|---|---|
--space-28 | 28px | Between a page’s sections on a desk. |
--space-40 | 40px | Above a page’s first heading. |
--space-48 | 48px | The padding of an empty state. |
--space-64 | 64px | The room under a page. |
Type
| Token | Value | Use |
|---|---|---|
--fs-display | clamp(48px, 5vw + 24px, 84px) | The oversize numeral or opening line of a page with one thing to say. Fluid, 48 to 84 px. |
--fs-section | clamp(22px, 1vw + 16px, 28px) | A section title in the serif. Fluid, 22 to 28 px. |
--lh-none | 1 | A single line of display type. |
--lh-tight | 1.1 | Serif display lines that wrap. |
--lh-snug | 1.3 | Card titles and short labels. |
--lh-base | 1.5 | Body text. |
--lh-loose | 1.7 | Long-form prose (the manual, the changelog). |
--measure | 66ch | The longest a line of prose should run. |
Motion
| Token | Value | Use |
|---|---|---|
--dur-instant | .08s | A press: the control has to feel it at once. |
--dur-slower | .5s | A panel or a sheet travelling a long way. |
--dur-loop | 1.4s | One sweep of a skeleton’s shimmer. |
--dur-pulse | 1.6s | One beat of a live dot. |
--dur-blink | 1.2s | One blink of a starting indicator. |
--dur-glow | .6s | A highlight fading out: a fresh row settling into the list. |
--ease-in | cubic-bezier(.4, 0, 1, 1) | Something leaving. |
--ease-emphasized | cubic-bezier(.2, 0, 0, 1) | Something arriving with intent: a dialog, a toast. |
--ease-spring | cubic-bezier(.34, 1.56, .64, 1) | A control that settles: it overshoots by a few per cent and comes back. |
--stagger | .04s | The delay between one row of a list and the next as they reveal. |
--lift | -1px | How far a hovered card rises. Zero under reduced motion. |
--press-scale | .98 | How far a pressed button shrinks. One under reduced motion. |
Layers
| Token | Value | Use |
|---|---|---|
--z-base | 0 | The page. |
--z-dropdown | 45 | A menu under the top bar. |
--z-drawer | 65 | The help drawer. |
--z-modal | 80 | A dialog that is not in the top layer. |
--z-tooltip | 90 | A tooltip, above everything but the skip link. |
Focus
| Token | Value | Use |
|---|---|---|
--focus-color | var(--moss) | The keyboard focus ring. |
--focus-width | 2px | Its thickness (WCAG 2.4.13 asks for 2 px). |
--focus-offset | 2px | The gap between the control and its ring. |
Icons
| Token | Value | Use |
|---|---|---|
--icon-moon | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7z'/%3E%3C/svg%3E") | The moon on the theme button, drawn as a mask so it takes the button’s ink. |
--icon-sun | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4'/%3E%3C/svg%3E") | The sun, shown on a dark page. |
Density
| Token | Value | Use |
|---|---|---|
--control-h | max(28px, calc(32px * var(--density))) | The height of a button or a field. Follows density, floored at 28 px. |
--control-h-sm | max(24px, calc(26px * var(--density))) | The height of a small control: a chip, a segment. |
--row-h | calc(48px * var(--density)) | The height of a list row. |
Legacy aliases
A handful of tokens from before the v3 redesign are kept as aliases of the current set, so pre-redesign partials stay visually consistent without being rewritten. Prefer the modern token in new code.
| Legacy alias | Resolves to |
|---|---|
--bg-card | var(--surface) |
--bg-hover | var(--surface-2) |
--text | var(--fg) |
--text-muted | var(--fg-3) |
--accent | var(--moss) |
--accent-dim | var(--moss-ink) |
--success | var(--moss) |
--danger | var(--rare) |
--radius | var(--r-md) |
--input-bg | var(--surface) |
--conf-high-bg | var(--conf-high-soft) |
--conf-mid-bg | var(--conf-mid-soft) |
--conf-low-bg | var(--conf-low-soft) |
One alias differs by theme, because amber is a fill and not a text colour:
| Legacy alias | Light | Dark | Use |
|---|---|---|---|
--warning | var(--dawn-ink) | var(--dawn) | Warning text: the dark amber ink on paper, the bright amber on night. |
--accent-rgb (a comma-separated RGB triple, listed above) is the one non-OKLCH
value, kept for the few rules that still need rgba() with a variable alpha.
What stays literal
A literal is allowed when it is not a design choice that a theme or a density change should move. The clusters that remain, and why:
| Cluster | Why it is not a token |
|---|---|
Species identity hues (--sp, set per bird) | Data, computed per species at render time. |
| The migration ridgeline’s bands (one hue per species, computed from the name) | Identity as data: the same bird is the same colour on every page. Everything else a chart draws reads --chart-* or --heat-*. |
black in a mask-image | An alpha mask has no colour of its own; only its alpha is read. |
| The sizes of one-off display glyphs and numerals (gauge and tile numerals, a 404 code, the palette’s search glyph) | Per-surface display type sized to its one container. Each is named, with a reason, in FONT_SIZE_EXEMPT in tests/design_tokens_are_the_only_literals.rs; a new one is a decision. |
Optical gaps off the --space-N ladder (3, 5, 7, 9, 11, 13 px and the odd paddings) | Adjustments to one component’s own balance. The rule is the other way round: a pixel count that has a rung is that rung, and the test fails on one that is not. |
The geometry of a keyframe (@keyframes) | Animation frames are not a design choice a theme moves. The durations and curves that drive them are tokens. |
A colour map’s stops (routes/spectrogram/colormap.rs) | Data, not styling: a spectrogram is an image drawn pixel by pixel, and a theme must not change it. Named in DATA_FILES in the same test. |
Colours inside the admin pages’ own <style> blocks, and in the SVG a chart renderer writes | Counted, not forbidden: tests/design_tokens_are_the_only_literals.rs holds each file to the number it has now, so none can grow. |
Adding or changing a token
The token set was closed by the v3 spine
(protect list:
“OKLCH token set: zero new tokens”), and was then opened once, on purpose, by a pass
that measured what the stylesheet actually wrote (the numbers are in
Known problems)
and added the ramps, the layered surfaces, the tinted shadows, the rest of the motion,
layer and radius ladders and the focus and density tokens. Each replaced a cluster of
ad-hoc literals or described a thing the stylesheet could not yet say, rather than
introducing a new idea. A refactor of the first kind is proven pure with
tools/visual-qa/style-snapshot.mjs (snapshot every element’s computed style before,
and after, from the same running fixture, and diff).
- Build new UI from existing tokens. Compose them into new CSS classes; that is the additive path and needs no token change.
- A genuinely new token is a design decision. Justify it in the PR, give it
values for both themes when it is a colour (keep light and dark in
lockstep), add it to this page in the same commit, and prefer extending a hue
family (
-soft/-ink) over inventing a new hue. - A token whose value differs between the themes also goes in
print.css. - Mix a tint in
oklab, neveroklch.color-mix(in oklch, var(--moss) 16%, var(--surface))interpolates hue, and a neutral has none (light--surfaceisoklch(100% 0 0), so hue 0; dark is 250): the amber tint came out pink (hue 10), the moss one brown (24), and in dark the warning toast was magenta (344).in oklabmixes the colour’s coordinates and has no hue to carry. A test refuses a newin oklch, andtools/visual-qa/tokens.mjsreads the hue of what each tinted surface paints. - Never hard-code a colour or pad in a component — read the token, so a future theme or density change stays a one-line edit.
Integrations Reference
Wiring BirdNet-Behavior into the rest of your stack — MQTT, Home Assistant, Prometheus, BirdWeather and webhooks.
Weather
Off by default — a fresh install contacts no weather service until
BNB_WEATHER_ENABLED=1. Once on, a background task polls every 30 minutes,
stores hourly rows, and paints them as a signal-context band behind the
day-strip and dawn-chorus charts.
Four upstreams, chosen with the WEATHER_PROVIDER key in birdnet.conf:
WEATHER_PROVIDER | What it is | Needs |
|---|---|---|
open-meteo (default) | Free gridded forecast, no key, and self-hostable — point BNB_WEATHER_BASE_URL at your own instance | nothing |
met-no | The Norwegian Meteorological Institute’s Locationforecast. Keyless, and a better model over Europe | nothing |
wunderground | Your own personal weather station’s current observations | WEATHER_STATION_ID and WEATHER_API_KEY |
pirate-weather | Pirate Weather’s hourly forecast, a Dark Sky-compatible API blending several models | WEATHER_API_KEY (free tier available) |
# birdnet.conf — read the anemometer in the garden, not a forecast for the county
WEATHER_PROVIDER=wunderground
WEATHER_STATION_ID=KMAHANOV10
WEATHER_API_KEY=…
The last one is the interesting one. A gridded forecast is a model’s opinion about a cell several kilometres across; a personal weather station is an instrument ten metres from the microphone. For asking why the birds were quiet on Tuesday, the instrument wins.
The API key can be mounted from a file rather than written into the config, the
same way the other credentials can:
BIRDNET_WEATHER_API_KEY_FILE=/run/secrets/weather_api_key.
eBird
“Has anybody else seen this bird around here lately?”
The station already has a geographic opinion about which species are plausible — the BirdNET range model. That opinion is climatological: it knows a Common Swift is expected here in July, and it has no idea the first one of the year arrived last Tuesday. eBird’s recent observations are the other kind of evidence entirely: a person stood near here within the last fortnight and wrote down what they saw.
Off unless you give it a key. There is no enable flag — eBird requires an API key for every endpoint, so the key is the opt-in, and a station without one never contacts eBird. Free keys: https://ebird.org/api/keygen.
# birdnet.conf
EBIRD_API_KEY=…
| Key | What it does | Default |
|---|---|---|
EBIRD_API_KEY | Your eBird token. Absent ⇒ the whole feature is off | (none) |
EBIRD_REGION | An eBird region code (US-MA, GB-ENG-CAM). Empty ⇒ use the station’s own coordinates | (empty) |
EBIRD_DIST_KM | Search radius when using coordinates, 0–50 | 25 |
EBIRD_BACK_DAYS | Days of history to ask about, 1–30 | 14 |
Leaving EBIRD_REGION empty is usually right: “reported within 25 km” says
far more than “reported somewhere in this state”, and the station already
knows where it is. Set a region only if your area genuinely has no eBird
observers within 50 km — a county or state will find some.
Your coordinates are sent rounded to two decimal places, roughly a kilometre. That is all eBird documents that it accepts, and all it needs.
The key can be mounted from a file like the other credentials:
BIRDNET_EBIRD_API_KEY_FILE=/run/secrets/ebird_api_key. BNB_EBIRD_BASE_URL
points the client at a caching proxy instead of api.ebird.org.
Where it shows up
Two places, both of them corroboration rather than filtering:
- A detection’s detail page gains a Reported nearby card when somebody reported that species in the station’s neighbourhood recently.
- The suspect-species report (Settings → Data) marks a flagged species that somebody reported nearby, and its Exclude confirmation says so — because that is a strong reason not to exclude it.
What it will never do
eBird’s silence is not evidence against a species, and the station never treats it as such. eBird coverage follows birdwatchers, not birds: a well-watched county produces hundreds of checklists a week and a quiet valley produces none, ever, for anything. If absence from eBird counted against a species, the station with nobody nearby to confirm anything — the one that most needs an automated check — would have its entire list flagged.
So corroboration only ever argues for a species. A species eBird says nothing about is displayed exactly as it was before you configured this, and no verdict anywhere changes.
When eBird is unreachable
The last answer is cached to disk and survives a restart. A station whose uplink is down keeps showing the corroboration it had, and pages label an answer older than a day as the last one eBird gave rather than hiding it or passing it off as current. The snapshot refreshes every six hours.
A WEATHER_PROVIDER nobody implements does not start the poll. That is
deliberate: a station configured to read its own anemometer and quietly served a
county forecast instead looks exactly like a station that is working.
Two things to know about what each provider can and cannot fill in:
- MET Norway reports no weather code. It describes the sky with a symbol
string (
partlycloudy_day) rather than a WMO number, and mapping one onto the other would be a table of guesses sitting in a column that reads as fact. The column stays empty. - A personal weather station reports no cloud cover, because it measures the
air rather than the sky. Its precipitation is the hourly rate — not
Wunderground’s
precipTotal, which accumulates since local midnight and would read as a downpour by evening after one morning shower.
MQTT
A pure-Rust MQTT 3.1.1 client publishes detections to any broker (Mosquitto, EMQX, Node-RED, …). Enable it with at least:
BIRDNET_MQTT_HOST=192.168.1.10
# optional: port, username/password, QoS, retain, and a topic prefix
Topics
Topics are built from a configurable prefix (default birdnet):
| Topic | Payload | Retained | Published by |
|---|---|---|---|
birdnet/detection/<Scientific_Name> | A detection (JSON, below). Spaces in the name become underscores. | your MQTT_RETAIN setting | the station, per detection |
birdnet/status | online or offline | yes | see below |
birdnet/stats/today | {"count": N} — detections so far today, station-local | yes | the station, every 5 minutes |
Subscribe to everything with birdnet/detection/#.
How the station reports that it is gone
birdnet/status is not published by the detection stream. The station holds a
second, otherwise idle connection to the broker whose only job is to carry an
MQTT last will, and the two connections use different client identifiers
(<client_id> and <client_id>-presence) because a broker must disconnect an
existing session when a second one claims its identifier.
| What happened | birdnet/status becomes | Who published it |
|---|---|---|
| The station started | online | the station |
systemctl stop, an upgrade, any clean exit | offline | the station |
| Power cut, crash, cable pulled, Wi-Fi gone | offline, within ~45 s | the broker, from the will |
| The broker itself went down | unchanged | nobody — see below |
The last row is the honest limit, and it is why birdnet_mqtt_connected exists
as a Prometheus gauge: a station cannot report on a broker that is not there,
so that case needs a signal that does not travel through the broker.
The keepalive is 30 seconds and the broker publishes the will after 1.5 of those, which is where the ~45 s comes from.
Detection payload
{
"timestamp": "2026-05-22T21:10:31",
"scientific_name": "Melospiza melodia",
"common_name": "Song Sparrow",
"confidence": 0.83,
"confidence_pct": 83,
"file_name": "Song_Sparrow-...wav",
"species_first_detected_at": "2026-03-14T06:02:11",
"species_last_detected_at": "2026-05-21T19:47:03"
}
file_name is left out when no clip was saved, and rtsp_id (the stream a
detection came from) appears only for an RTSP source.
species_first_detected_at and species_last_detected_at are the species’
earliest and latest detections before this one, in the same local
wall-clock form as timestamp; rejected detections are not counted, nor are
imported ones when imports are excluded from analytics. The two keys say
three different things:
| The keys are | Meaning |
|---|---|
| timestamps | the station had heard this species before |
both null | it had not — this is the first detection ever |
| absent | the history could not be read, so nothing is claimed |
An automation for “a species never heard here before” should test for
null, not for a missing key: a missing key is a read failure, not a first.
Home Assistant
BIRDNET_MQTT_HOST=192.168.1.10
BIRDNET_MQTT_HA_DISCOVERY=1 # or the --mqtt-ha-discovery flag
With discovery enabled, the station publishes Home Assistant MQTT discovery config under the homeassistant/ prefix, so it registers itself automatically — no YAML to write. Four entities appear:
| Entity | Type | Reads |
|---|---|---|
| Last Detected Bird | sensor | birdnet/detection/# |
| Detection Confidence | sensor | birdnet/detection/# |
| Station Status | binary_sensor (connectivity) | birdnet/status |
| Detections Today | sensor | birdnet/stats/today |
Drop them on a dashboard or trigger automations from them — “flash the porch light when an owl is heard after dark”, or, from Station Status, “tell me when the garden station stops answering”.
If you ran an earlier version. Station Status and Detections Today were registered but never fed, so both showed as unknown for the life of the station and no automation could be built on either. Discovery configs were also published unretained, which meant Home Assistant lost all four entities every time it restarted, until the station was restarted too. Both are fixed; the entities repopulate the next time the station starts.
Prometheus metrics
GET /api/v2/metrics returns the standard Prometheus text format. The exported series:
| Metric | Type | Meaning |
|---|---|---|
birdnet_detections_total | counter | Total detections since start, labeled by species and integer chunk_offset (s). |
birdnet_detections_stored | gauge | Detection rows currently in the database, rejections included. Falls when rows are deleted or purged, which is why it is a gauge and does not wear a _total suffix. |
birdnet_detections_rejected | gauge | Of those, the ones a reviewer has marked rejected. stored - rejected is what the web UI displays. |
birdnet_species_distinct | gauge | Distinct species detected, excluding rejected detections. |
birdnet_inference_duration_seconds | histogram | Per-chunk inference latency (decode → prediction). |
birdnet_db_write_duration_seconds | histogram | SQLite insert latency for one detection row. |
birdnet_files_analysed_total | counter | Audio files the pipeline finished analysing, labeled by source. The series that separates “the model is answering nothing” from “the pipeline is not running” — every other signal is downstream of a prediction the model made, so both states leave them flat and empty. A 15-second segment length gives about 5 760 a day per source. |
birdnet_audio_analysed_seconds_total | counter | Seconds of audio the pipeline finished analysing, labeled by source: the data rate. With nothing waiting (birdnet_analysis_queue_depth), rate() of it is about 1 for a source that delivers continuously, above 1 while a backlog is analysed, and below 1 when audio is missing. That last case is what birdnet_files_analysed_total cannot show: a camera that drops packets still yields a 15-second segment file, with less audio in it. Read from each file’s header after analysis. |
birdnet_segments_dropped_total | counter | Raw segments the watcher announced that were gone before the pipeline read them, labeled by source: recorded audio that was never analysed. Rising means the stream directory is being drained faster than inference keeps up. A segment the pipeline is reading is claimed and never the one the purge takes. |
birdnet_analysis_queue_depth | gauge | Raw segments waiting for analysis after the daemon’s last sweep. Climbing means inference is slower than real time; above 40 the daemon sheds (below). Absent until the daemon reports. |
birdnet_segments_shed_total | counter | Raw segments the shed policy chose not to analyse, labeled by reason: backlog (more than 40 waiting) or thermal (the board at or above 80 °C, or throttling). One segment in two is skipped while either holds, so the queue drains at twice the rate and the skipped audio is counted rather than lost. |
birdnet_model_failing | gauge | 1 while a classifier is failing — at least 10 analysis windows failed in a row, none answered since — else 0, labeled by model. A window fails when the classifier returns an error or answers with NaN or infinite scores. Absent until the daemon has analysed a file. |
birdnet_model_consecutive_failed_windows | gauge | Analysis windows a classifier has failed since it last answered one, labeled by model. |
birdnet_model_windows_total | counter | Analysis windows a classifier has run since it was loaded, answered or failed, labeled by model. |
birdnet_model_last_success_timestamp_seconds | gauge | When a classifier last answered a window cleanly (Unix seconds), labeled by model. Absent for one that has not. |
birdnet_audio_source_up | gauge | 1 if an audio source is producing samples, else 0, labeled by source (e.g. local, cam1). One series per capture source. |
birdnet_detection_silence_seconds | gauge | Seconds since the most recent stored detection — the end-to-end “is it actually detecting?” freshness signal (see System Health). Absent until the first measurement / on a station with no detections yet. |
birdnet_outbound_queue_depth | gauge | Store-and-forward uploads parked for replay after a network failure, labeled by kind (e.g. birdweather). A depth that only grows means the uplink or token has been broken for a while. |
birdnet_watchdog_pings_total | counter | Successful systemd WATCHDOG=1 notifications sent. |
birdnet_detection_write_failures_total | counter | Detections the model produced and the database refused — a detection the station heard and could not keep. Should stay 0; see below. |
birdnet_clock_synced | gauge | 1 when the system reports its clock as synchronised to a time source, 0 when it does not. Absent when nothing can answer — every container without systemd, where the clock belongs to the host. A 0 here is the quiet failure: the dates stay plausible, so every count and chart looks healthy while the hours drift, and a season filed under the wrong times only shows up when someone tries to compare it against another station. |
birdnet_mqtt_connected | gauge | 1 when the station’s MQTT presence session is connected to the broker, 0 when it is not. Absent when MQTT is not configured. This is the one MQTT signal that does not travel through the broker, so it is what tells you the broker is down rather than the station — while it reads 0, the broker has already published the station’s last will and Home Assistant shows it offline, but nobody has told you which of the two failed. |
birdnet_notifications_dropped_total | counter | Notifications that never left the station, labeled by reason: circuit_open (the destination is considered down after three consecutive failures), rate_limited (over NOTIFY_RATE_PER_MINUTE), send_failed (the destination refused or was unreachable), no_destination (nothing configured that this station can deliver to). Detection notifications dominate this on a busy station; an alert about the station itself is exempt from the rate limit and is retried at every poll until it lands, so circuit_open rising while a health condition is open means the operator is not being told. |
birdnet_noise_floor_dbfs | gauge | The station’s measured background noise floor per capture source, averaged over the last 7 days. Typical quiet outdoor background is −60 to −40 dBFS. |
birdnet_noise_floor_drift_db | gauge | How far a source’s noise floor has moved against its own preceding 30-day average, in dB. Absent for a source with no baseline yet — “never measured” is not “unchanged”. |
birdnet_info | gauge | Build information — always 1, with the release in its version label. |
birdnet_uptime_seconds | gauge | Process uptime in seconds. |
birdnet_cpu_count | gauge | Number of CPU cores available. |
birdnet_process_resident_memory_bytes | gauge | Resident memory size in bytes. |
birdnet_analytics_enabled | gauge | Whether DuckDB analytics is enabled. |
birdnet_detections_dropped_total | counter | Classifications produced and then discarded, labeled by reason. |
birdnet_capture_restarts_total | counter | Capture processes restarted by the supervisor, labeled by source. |
birdnet_capture_stalls_total | counter | Capture processes found alive but producing no segments, labeled by source. |
birdnet_occurrence_filter_active | gauge | 1 when species occurrence filtering is running, 0 when every species the classifier knows is admitted. |
birdnet_occurrence_candidates | gauge | Species the occurrence filter currently admits. |
birdnet_orphaned_clips | gauge | Detections whose clip the disk no longer had, found and stamped by the last daily reconciliation pass; absent until one has run. |
birdnet_analytics_mirror_failures_total | counter | Detections the database accepted and the DuckDB analytics copy refused since process start. |
birdnet_purge_ineffective | gauge | 1 when the last disk-full purge removed recordings without lowering usage: the card is full of something the purge cannot reach, and it has stopped deleting until usage falls. |
birdnet_disk_used_percent | gauge | Space used on the volume, labeled volume="data" (the database’s directory) and, when it is a different filesystem, volume="scratch" (the temporary directory). |
birdnet_disk_available_bytes | gauge | Bytes this user can still write on the volume, same labels. |
birdnet_cpu_temperature_celsius | gauge | CPU temperature from the board’s sensor; absent where none is exposed. |
birdnet_pi_throttled_bits | gauge | A Raspberry Pi firmware’s get_throttled mask: bits 0–3 now (under-voltage, frequency capped, throttled, soft temperature limit), bits 16–19 since boot. Absent off a Pi. |
birdnet_maintenance_last_run_seconds | gauge | When the scheduled job last completed, seconds since the Unix epoch, labeled by job (backup_vacuum, integrity_check, offsite_backup). |
birdnet_maintenance_last_ok | gauge | Whether the job’s last run succeeded (1) or failed (0); absent for a job that records no verdict. |
birdnet_http_responses_total | counter | Web responses served, labeled by status class. |
birdnet_http_request_duration_seconds | histogram | Web request handling latency in seconds. |
Renamed. Three gauges used to carry a
_totalsuffix, and one of them —birdnet_detections_total— collided with the per-species counter above. A metric name may have only one type, so the exposition was rejected outright bypromtool check metrics, Telegraf’sinputs.prometheusand the Python client, and a Prometheus server silently merged a decreasing gauge into the counter. The gauges are nowbirdnet_detections_stored,birdnet_detections_rejectedandbirdnet_species_distinct; the counter keeps the_totalname that the convention reserves for it. Update any dashboard or alert rule that referenced the old gauge names — the bundleddocs/grafana-dashboard.jsonalready is.
GET /api/v2/health?strict=1 is the probe to point a pager at. The plain
/api/v2/health answers 200 whenever the database is serving, which is right
for the container health check — Docker restarts an unhealthy container, and a
station whose detection daemon is down is exactly the one that must stay up to
be diagnosed. The strict form additionally returns 503 when the detection
daemon is not running, when the data disk is critically full or df cannot
read it, or when the admin-password bootstrap failed at start, so a monitor
that should wake a human can get a red out of the same endpoint. A data volume
that is not taking writes at all — a read-only remount, a card full to the
byte, a mount that has gone away — is 503 on both forms. Both report
detection_daemon, detection_silence_secs and data_volume in the body
either way.
The freshness and queue-depth gauges are the two you want alerts on for an
unattended station:
birdnet_detection_silence_seconds > <your quiet period> catches a station
that has gone deaf even though every process looks healthy, and a steadily
climbing birdnet_outbound_queue_depth catches a broken uplink before a
season’s uploads pile up. Alert on any increase in
birdnet_detection_write_failures_total as well: it is zero on a healthy
station, and non-zero means a full or read-only disk, a locked database, or the
one local hour daylight-saving repeats each autumn (see
Time synchronisation).
The two noise-floor series answer the question no other gauge here can. A
microphone that fails outright is caught by birdnet_audio_source_up and by the
detection deadman. A microphone that merely goes deaf — water in the capsule,
a spider’s web across the port, a connector loosened by a year of thermal cycling
— keeps its process alive and its gauge at 1, and shows up only as fewer
detections. So does the end of the breeding season. The background noise floor
does not stop when the birds do, so a large, sustained negative
birdnet_noise_floor_drift_db on one source, with nothing else changed, points at
the equipment.
Measured on this project’s own test recording, a capsule at 2 % sensitivity reads 35 dB lower on the noise floor (−77.3 against −42.5 dBFS) while its SNR barely moves (2.9 against 2.7 dB) — attenuation scales signal and background together, so SNR is blind to it and the noise floor is not.
No threshold is shipped, deliberately: a noise floor moves for real reasons —
weather, season, a road, leaf-out — and a number picked without a season of real
recordings behind it would fire on all of them. Watch the series for a few weeks,
then set an alert from what your own site actually does. A starter Grafana
dashboard lives at
docs/grafana-dashboard.json.
# prometheus.yml
scrape_configs:
- job_name: birdnet
metrics_path: /api/v2/metrics
static_configs:
- targets: ["birdnet.lan:8502"]
BirdWeather
Share detections with the BirdWeather network using your station token:
BIRDNET_BIRDWEATHER_TOKEN=your-station-token
Each detection goes up with its audio: the saved clip is uploaded first as a
soundscape, and the detection post carries that soundscape’s id and the
detection’s start and end inside it, so what BirdWeather shows for this
station can be listened to. The id is kept on the detection row
(birdweather_soundscape_id). If the clip cannot be uploaded the detection
still goes, without it. Posts are stamped with the station’s local time and
its UTC offset. algorithm is sent only for a model BirdWeather has a name
for (2p4, the V2.4 classifier), and the code decides that from the classifier
file’s name without its extension: it must contain V2.4, as BirdNET-Pi’s
BirdNET_GLOBAL_6K_V2.4_Model_FP16 does. The V3.0 preview is left unnamed
rather than mislabelled. The file --install-model birdnet-v24 writes is
birdnet-v24.onnx, whose name does not contain V2.4, so by that rule a
station on the default classifier sends no algorithm. That is read from
algorithm_for_model and model_name_of, not observed against BirdWeather.
Uploads are resilient to a flaky uplink: a post that fails after its
in-flight retries is parked in the local database and replayed oldest-first
when the network returns (bounded so a months-long outage can’t fill the disk).
Detection never blocks on the network. Watch the
birdnet_outbound_queue_depth{kind="birdweather"} gauge and the “Queued
Uploads” figure on Settings → Health; full mechanics are in the
Field Deployment Runbook.
Self-hosted ingest (sensitive species)
Programmes tracking rare or endangered species — where a public observation map is a poaching risk — can keep observation locations under their own governance by pointing uploads at their own endpoint that implements the BirdWeather station API shape:
# config key BIRDWEATHER_URL, or env BIRDNET_BIRDWEATHER_URL
BIRDNET_BIRDWEATHER_URL=https://ingest.example.org/api/v1
Only the host changes — the /stations/<token>/... path shape is preserved, and
the offline queue and ordered replay above come with it. The active endpoint is
logged at startup so a misdirected station is visible in the first journal
lines, not after a silent season.
Apprise & webhooks
For Discord, Slack, Telegram, ntfy, Gotify, Pushover or a plain JSON webhook, set BIRDNET_NOTIFY_URLS — Apprise URL syntax, delivered in-process. For the remaining Apprise services, point BIRDNET_APPRISE_URL at an Apprise API server or BIRDNET_APPRISE_CONFIG at an apprise config file. For fully custom integrations, the alert-rules engine can fire a webhook with a configurable method and body when a detection matches your conditions (species pattern, confidence range, hour, day-of-week).
See Notifications & Integrations for the channel walkthrough.
Architecture
BirdNet-Behavior is a single binary built from eight Rust workspace crates, each with a clear responsibility:
birdnet-behavior (single binary)
├── birdnet-core Audio capture, decode, resample, mel spectrogram, ML inference
├── birdnet-db SQLite (OLTP) + DuckDB (OLAP), migrations, resilience, backup
├── birdnet-web axum web server, REST API, WebSocket, HTMX templates
├── birdnet-integrations BirdWeather, Apprise, email, Wikipedia images, MQTT, auto-update
├── birdnet-behavioral DuckDB behavioral analytics (built in; on by default)
├── birdnet-timeseries Activity, diversity, trend, peak, gap, session analytics
├── birdnet-migrate BirdNET-Pi schema detection, validation, import
└── birdnet-scheduler Solar calculations, recording-window scheduling
Design principles
- One self-contained binary — the ONNX Runtime and DuckDB analytics engine are compiled in; no Python, no virtualenv. Deploy by copying a file (it links the host’s system glibc, ≥ 2.39).
- Synchronous,
unsafe-free library crates —birdnet-coreandbirdnet-dbare blocking; async (Tokio) lives only in the application layer. Blocking work runs ontokio::task::spawn_blocking. - Hand-rolled error types in libraries (no
anyhow/thiserror), withunsafeforbidden workspace-wide (unsafe_code = "forbid"),missing_docsenforced, and Clippy pedantic + nursery denied in CI.
Deep-dive design documents
The complete set of 16 design documents lives in the repository under docs/architecture/; the most relevant:
| Document | Contents |
|---|---|
| 01 — Motivation | Why Rust, not Python or Go |
| 02 — Architecture | Single-binary design and workspace layout |
| 03 — Coding standards | Linting, error handling, modularity, testing |
| 05 — Audio pipeline | Capture, decode, resample, segmentation |
| 06 — ML inference | Mel spectrogram and the BirdNET+ model |
| 07 — Database | SQLite + DuckDB, migrations, resilience |
| 10 — Deployment | Cross-compilation, CI/CD, systemd |
The index lives at docs/RUST_ARCHITECTURE_PLAN.md.
Credits & License
Who built what this project stands on, and the terms it is released under; read this before reusing the software or its data, or if you are the author of one of the projects named.
Lineage & attribution
BirdNet-Behavior stands on the shoulders of the conservation-bioacoustics community. It is a rewrite of BirdNET-Pi in Rust, derived from it and licensed accordingly (the repository’s LICENSE says it “is derived from and inspired by” the upstream works below).
- BirdNET — the ML model, by the K. Lisa Yang Center for Conservation Bioacoustics, Cornell Lab of Ornithology.
- BirdNET-Pi — the original Raspberry Pi implementation, by Patrick McGuire.
- BirdNET-Pi fork — the maintained fork, by Nachtzuster.
- duckdb-behavioral — the behavioral-analytics engine, by tomtom215.
License
Licensed under CC BY-NC-SA 4.0, matching the upstream BirdNET and BirdNET-Pi projects.
Non-commercial use only. You may not use this project to build a commercial product. See
LICENSEandLICENSE-UPSTREAMfor full terms.
Bird photographs are cached from Wikimedia Commons under CC BY-SA, with attribution shown on each image.
Related projects
| Repository | Description |
|---|---|
| duckdb-behavioral | ClickHouse-inspired behavioral analytics for DuckDB |
| quack-rs | SDK for building DuckDB extensions in Rust |
| mallardmetrics | Single-binary web analytics (axum + DuckDB) |
| LyreBirdAudio | RTSP audio streaming |
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md and the architecture docs to get oriented.