Repository navigation
BirdNet-Behavior v0.18.0
Security
-
tokio1.54.1 (RUSTSEC-2026-0384). 1.54.0 has a race betweentokio::time::Interval
and the runtime's time driver that can free a timer's waker while the driver is firing it (a
use-after-free in safe Rust). The station usesIntervalin twelve places, among them the
systemd watchdog ping, the heartbeat, the dead-man timer, the maintenance scheduler and the
MQTT presence pings. The advisory was issued 2026-10-22 and is fixed in 1.54.1; the
lockfile now pins it.SleepandTimeoutare not affected. -
ctutils0.4.4 (RUSTSEC-2026-0385). Before 0.4.4 the constant-time helpers in this
crate could compile to a branch on a secret-dependent value (mask generation on common
platforms,u128comparisons on 32-bit targets); a branch on a secret can leak it through
timing. The station reaches it through the Argon2 password-hashing dependencies
(argon2→blake2→digest); whether the station's own hashing and verification call the
affected functions was not traced. Pinned in the lockfile; thebirdnet-dblibrary tests
(497) and the account tests pass on it. -
Admin forms no longer print a stored credential. Settings and Audio showed saved
passwords, tokens and camera addresses in clear in the page source. A secret now shows as
***REDACTED***(or empty), an unchanged mask saves back to the stored value, and a mask
with nothing behind it is refused without echoing it. A test drives 21 admin and station
routes and fails if a secret appears in any body. The support bundle carries settings
saved in the browser and the audio sources, masked. -
A viewer cannot read the live log, and the cross-site check refuses
Sec-Fetch-Site: cross-sitewrites. -
The cross-site-request check now covers HTTP/2. Browsers speak HTTP/2 to the
station's TLS listener, which sends noHostheader, and the check treated "noHost"
as "nothing to compare" and let the request through. It now takes the authority from the
Hostheader or the request address, and refuses a request that carries anOriginor
Refererit cannot compare. Found by review; the new test drives a real HTTP/2 client
and failed on the old code (a cross-origin POST, a cross-siteRefererand a forged
sign-in all got through). -
EMBED_ORIGINSapplies only to the dashboard, the wall display and the recordings
page. Sign-in, onboarding, restore,/admin/*and/station/*can never be framed,
and wildcards over shared suffixes (*.co.uk,*.github.io,*.pages.dev,
*.ui.nabu.casa) are refused (a hand-kept list, not the Public Suffix List). The old text
said the session cookie is withheld from framed pages; that holds only for cross-site
embedders: a Home Assistant on the same host and another port is same-site, so a
signed-in administrator who is same-site with a listed embedder sees the framed
dashboard's admin controls. -
A viewer account can no longer read the BirdNET-Pi import's settings lines (they
carry credentials), Diagnostics, the process id, or file paths in the model panel. The
setup wizard no longer prints a camera's password, and the first admin password can be
set only once (two simultaneous first-run submissions no longer both succeed). -
BirdWeather's position blur fails closed and cannot be averaged away. A mistyped
BIRDWEATHER_THRESHOLDused to discard a validBIRDWEATHER_LOCATION_ACCURACY, and an
unusable radius (5km,60000) posted the exact position. Now a bad radius turns
uploads off, the two settings are read independently,--doctorreports both, and the
offset is one fixed point per station (a seed inbirdweather-blur-seedbeside the
database) so many posts cannot be averaged back. It is not hidden from anyone who learns
the offset, and changing the radius or losing the seed draws a new one. -
Imports and installs are harder to abuse. The settings-import lines download uses a
random 128-bit token, works once and expires after ten minutes; credential-named keys,
unknown keys and address user-info are not shown in the preview; the heartbeat address
goes through the outbound-address guard; a model install stops at the pinned size, uses
a unique exclusive staging file and re-checks the length; Settings refuses a stream
address with no known scheme and a heartbeat address that is not http(s). -
The public MQTT status no longer carries file paths or the broker address, a mangled
private key is never quoted in an error, and learned-threshold and weather-by-detection
reads no longer confirm a detection the owner rejected. -
"Look up my approximate location" no longer asks over plain HTTP. It asked
http://ip-api.com/json/, whose free tier has no TLS, so anyone on the path could
rewrite the coordinates and timezone it filled in, and the station's public IP
address crossed the network in clear. It askshttps://ipwho.is/(free, no key,
1,000 requests a day per address) and, only if that fails,https://get.geojs.io/;
the client refuseshttp://outright, redirects included. The answer is read
defensively: a coordinate that is missing, not finite, out of range or exactly
0.0, 0.0 is a failed lookup, and a timezone that is not shaped like an IANA name is
dropped. The wizard now says which services it asks and what they are told (the
station's public IP address, as before). A failed lookup still shows the same
message. Gates: the unit tests inroutes/admin/settings/handler.rs. -
The support bundle no longer carries the station's exact coordinates.
LATITUDEandLONGITUDEare shown to one decimal (about 11 km) in the
configuration and wherever the journal printed them (BirdWeather's start-up line,
a settings change); a value that is not a number is masked. The bundle is written
to be pasted into a public issue. A bundle made by an earlier version has them
exactly.--doctorprints neither. -
The CSP nonce now goes only onto the
<script>and<style>tags this server
wrote, and any other<scriptin a response is turned into text. The nonce used
to be stamped by a pass over the finished body onto every<scriptit found, a
visitor's included, so any one unescaped reflection was script execution that the
strict CSP existed to stop. Authored markup is now sealed with a per-process
128-bit token where it is defined, and the response pass (src/seal.rs) redeems
exactly those tags for the nonce and strips the token, so it never leaves the
process. Sealing alone was not enough: in a fragment htmx swaps in,
'strict-dynamic'admits a re-created script without a nonce, so the pass also
rewrites every unsealed<scriptto<script, in any letter case. Measured in
Chromium against a page that reflects its input unescaped: a full document and a
swapped fragment no longer run the reflected script, and the server's own fragment
script still runs. Gates:security::tests::a_script_reflected_into_a_page_does_not_come_out_as_a_script
(and its<style>counterpart),tests/every_script_on_every_page_carries_the_nonce.rs
(the 114 pages and partials of the visual-QA route list) and
tests/authored_markup_is_sealed.rs(a source scan). Not closed by this: an
unescaped reflection can still inject markup, including anhx-postattribute that
htmx would fire on load; escaping stays the primary defence, and
a_query_value_is_never_echoed_as_markup.rssweeps GET parameters only, not POST
bodies or stored values. -
Two pages put a URL value into their HTML unescaped.
/quarantine?filter=wrote
the raw value into anhx-getattribute and/pages/weekly-content?week=accepted
any ten characters as a date. Both are on the public router;?filter="><script>…
ran in Chromium on the station's origin, because the old nonce stamper trusted every
<script. A filter is now one of four known words and a week must beYYYY-MM-DD.
tests/a_query_value_is_never_echoed_as_markup.rsrequests every page and partial
route with eachStringquery parameter set to a probe and fails on one that comes
back as a tag or ends an attribute; against the unfixed code it reports exactly
those two routes. -
The local HTTPS CA can no longer vouch for a public website.
--tls-mode self-signedmakes a CA and a leaf it signs, and the operator imports the CA into
browsers and phones; the CA had no name constraint and its key (local-ca.key,
mode 0600) is readable by the service user, so anything running as that user could
mint a certificate for any site that every importing device would trust. The CA now
carries a criticalnameConstraintsextension permittinglocalhost,.local, the
loopback, private and link-local ranges and the names given with--tls-hostnames.
Measured with rustls's WebPKI verifier: a leaf forbank.example.comverified
before; after, it and leaves foraccounts.google.com,8.8.8.8and203.0.113.7
are refused while10.9.8.7,other-pi.localand::1still verify. One-time
cost: the CA from before this release is replaced at the next start (so is a CA
that cannot vouch for a newly added public name); devices that imported it import
the newlocal-ca.crtonce. Both log a warning. The key's temporary file is also
created with its final mode (create_private, which refuses a symlink at the
temporary name). -
A BirdNET-Pi database or CSV chosen for import, and a backup envelope read back
from storage, can no longer exhaust the station. (1) A 16 KiB SQLite file whose
detectionsis an FTS5 external-content table over a view of its own choosing
passed schema detection and then produced rows and allocations of its own choosing
(three rows ofhex(zeroblob(1e8))took the process from 120 MB to 2.4 GB). The
source is now opened withSQLITE_LIMIT_LENGTHat 1 MiB, refused when it defines
any virtual table, and refused when it is not a regular file. (2) CSV validation
buffered a line of any length (one 300 MB line took the process from 123 MB to
709 MB, and/dev/zero, which the form accepts as a path, ended it); it now uses
the importer's 1 MiB capped reader. (3) The importer logged the full text of every
unparseable CSV line, so naming any file as the source put its contents in the
journal and the support bundle; it logs the data-line number. (4) A backup
envelope's header carries its own argon2 cost, read before anything is
authenticated: a header naming 8 GiB of memory ended in an out-of-memory kill and
t_cost = u32::MAXwas still running after ten seconds. Reading is now bounded to
256 MiB, 16 passes and 16 lanes (this build writes 19 MiB, 2 and 1); an envelope
written by argon2 0.5 still decrypts. Reaching (4) needs write access to wherever
the backup is stored. -
Alert-rule webhooks could be pointed at a cloud metadata address (O-11).
Webhook requests, the Test button included, refuse link-local addresses
(169.254.169.254among them), the unspecified address and Azure's WireServer
(168.63.129.16), Oracle Cloud's192.0.0.192and Google's IPv6 metadata server
(fd20:ce::254), in every IPv4-in-IPv6 encoding, on the first request, on every
redirect and for every address a host name resolves to. Loopback, LAN and public
addresses are unaffected. Webhook requests no longer go throughHTTP(S)_PROXY
(the guard cannot see the address behind a proxy). A rule's custom auth header
(X-API-Keyor any name the rule chose) no longer follows a redirect to another
origin (reqwestdrops onlyAuthorizationandCookie): the request stops at the
cross-origin 3xx and reports it; a same-origin redirect still carries the header.
The policy is birdnet-go's (internal/httpclient/ssrf.go). -
A visitor behind some reverse-proxy setups could choose their own address. With a
trusted proxy that sets onlyX-Real-IP(nginx withproxy_set_header X-Real-IP $remote_addr;and noX-Forwarded-Forline), the visitor's ownX-Forwarded-For
won, so each request could claim a fresh address and walk past the rate limiter and
the sign-in throttle, and write a false identity into the audit log. It did not
bypass authentication. When the two headers name different visitors the request is
now attributed to the proxy, and the log says so once. The documented nginx and
Caddy configurations did not have the defect. Cost: a visitor who forgesX-Real-IP
is counted as the proxy, an identity shared with anyone on the station's own
machine, so they can spend that identity's sign-in throttle; having the proxy
overwrite or clearX-Real-IPcloses that too. -
Credentials stopped leaving the station through logs, errors and exports.
26 types holding a password, token, API key or a URL with one derivedDebug, which
prints every field (the SMTP, MQTT and ntfy passwords, the Discord, Telegram, Slack,
Gotify and Pushover tokens, the BirdWeather, eBird, Weather Underground and Pirate
Weather keys, RTSP and webhook URLs, the S3 signature, the login, onboarding and
account forms, the command line); each now prints<redacted>, and a source scan
keeps one from deriving it again. An RTSP camera's URL was logged at info on every
start, and ffmpeg's errors (Error opening input file rtsp://admin:…@cam/stream.)
at debug, in capture and in the live-stream log; both are written with the password
replaced (the user name and host stay). The log redactors removeduser:pass@and
passed the query through, where an SRTpassphrase=, a signed URL'stoken=and
MediaMTX'sstreamid=read:path:user:passkeep theirs; parameters named like a
secret and a stream id's user and password are masked now. A source with no label
was listed in the Live view's picker and on the first-run Today checklist by its
address,user:pass@included, to anyone on a password-protected station; it is
named by its host. The support bundle'sjournal.logwas the journal verbatim and
is now scrubbed of every configured secret, URL-valued settings' paths and tokens
and every URL's password (which also cleans lines an older build wrote); its error
log and the doctor's malformed-URL message redact every URL in a line, not the
first. The Apprise address is logged redacted and so are custom BirdWeather and
weather endpoints; Apprise's own error text (it names the URL it failed on, and in
an Apprise URL the token is often the host) is redacted and clipped before it is
stored; the BirdWeather Test button no longer puts the token into its error
message. A "redacted" alert-rule export still carried each webhook's full address
(for Home Assistant, Slack or Discord that is the credential); it keeps scheme and
host, and importing such a rule brings it in switched off and says so. Share links
(/r/{token}, where the token is the access) were logged with their full URI at
debug, into the journal and support bundles; requests are logged by the route
they matched, and the query string is not logged. -
RTSP credentials are visible in
ffmpeg's command line (not fixed).ffmpeg
takes its input address only as an argument and RTSP takes its user and password
only from that address. Logs and the support bundle redact it. The audio guide says
who can see it (anyone who can list processes on the host; nobody outside the
container under Docker) and what to do on a shared host: a read-only camera account,
or/procmounted withhidepid=2(the unit'sProtectProc=invisiblehides other
users' processes from the service, not the service from them). A way around it
exists and is recorded, not built:docs/TECH_DEBT.mdTD-5. -
--apply-configcan no longer be steered through a planted symlink. It runs as
root in/etc/birdnet, which the installer makes group-writable by the service
user, and wrote<target>.partand<target>.bak-<epoch>through a link planted at
either name. Both are now created withO_EXCL, mode0600, and given the
target's mode and owner on the open descriptor before any text is written. -
The installer's downloads are https-only, and the update check names the release,
notmain.curl/wgetfollowed a redirect to plain http over the connection
that carries theSHA256SUMSthe archive is checked against; they now pass
--proto '=https' --proto-redir '=https' --tlsv1.2/--https-only
(installer/test/downloads-are-https-only.sh). The admin "Check for updates" text,
the can't-replace-the-binary message and the release notes give theinstall.sh
attached to the release instead of pipingmain/install.shthroughsudo bash. -
The install one-liner no longer runs the development branch as root. README, the
manual, the architecture notes and the installer's messages piped
raw.githubusercontent.com/…/main/install.shintosudo bash: whatevermainheld
that second, with no release, checksum or provenance between. They now name
https://github.com/tomtom215/BirdNet-Behavior/releases/latest/download/install.sh
(listed inSHA256SUMSand attested), with--proto '=https' --tlsv1.2;
installer/test/install-one-liner-is-a-release-asset.shfails on a branch-pinned
form. Not done: the one-liner was not run end to end, and the Dockerquickstart.sh,
the compose files it downloads andscripts/hardware-test.shstill come frommain
(docs/HANDOFF.mdsays why). -
The Docker quickstart no longer leaves
/adminopen to the network.
quickstart.shpublished the dashboard on every interface with no password and wrote
.env(which can hold an RTSP URL with a camera's credentials) with the default
umask. It now generates a 22-character admin password as the bare-metal installer
does, writes a0600.env, and prints the password once at the end. A station whose
admin account has no password (a hand-made.env) now logs onewarnat start-up
naming what is open and how to close it. -
The session cookie's
Secureflag follows the request, notBNB_PUBLIC_URL.
--tls-modewithout that variable issued a cookie withoutSecureon an encrypted
connection, while anhttps://public URL made every sign-inSecure, including one
fromhttp://192.168.1.5:8502, where the browser drops the cookie and the sign-in
loops. A request is HTTPS now when it arrived on the station's own TLS listener, when
a trusted proxy says so (X-Forwarded-ProtoorForwarded: proto=, leftmost hop), or
whenBNB_PUBLIC_URLishttps://and the request'sHostis that address. A
reverse proxy should sendX-Forwarded-Proto; the nginx examples now do. -
A private station's pages are marked
no-store, and the service worker cannot keep one.
The private-mode gate now marks what it serves and its refusalsCache-Control: no-store
(a handler's own value wins; an open station is unchanged), and the worker refuses a
redirected response and honoursSKIP_WAITINGonly from its own origin. Correction to
the first draft of this entry: it said the worker "answered dashboard pages from cache".
It never did. It was registered at an address the server does not serve, so it did not
run; see Fixed for what it is now. Theno-storemarking still stands as a guard
against a browser or proxy cache. -
A feed address can no longer choose where its links lead.
?base=on/feeds/*
set the host of every item link, so a link on the station's own domain could point at
a phishing page. It is honoured only for the host the request was addressed to or the
host ofBNB_PUBLIC_URL/BNB_BASE_URL. A deployment whose proxy rewritesHostand
whose readers pass?base=must setBNB_BASE_URL. A lone carriage return in a
species name no longer starts a new iCalendar property. -
An uploaded migration database no longer waits in
/tmpor leaves SQLite's
-shm/-walbehind. The staged upload (up to 4 GiB) lives inmigration-staging/
beside the station's database, and removing it removes the bookkeeping files SQLite
creates when it reads a WAL-mode file. Before, on a distribution whose/tmpis a
tmpfs that was RAM charged to the service's memory ceiling. The migration guide no
longer says the source is "never modified": no row of it is, but SQLite may create
-shm/-walbeside a WAL-mode file read in place. -
The DuckDB extensions compiled into a build are pinned by sha256.
behavioraland
icuare native code the daemon loads with DuckDB's signature check bypassed. Four
places fetched them with an uncheckedcurl, andbuild.rschecked only that the
footer parsed and named the right engine.crates/birdnet-behavioral/extension_pins.rs
now lists the five builds the project ships (behaviorallinux_amd64 and linux_arm64;
iculinux_amd64, linux_arm64 and osx_arm64) andbuild.rsrefuses any other
extension or other bytes. Thelinux_amd64builds were also loaded by DuckDB 1.5.6
with unsigned extensions disallowed (genuine files load; a copy with one bit changed
is refused); the other three pins are the registry's bytes as served on 2026-10-14.
RELEASING.mdsays how to update a pin. -
rustls0.23.46, for four advisories published 2026-10-14: RUSTSEC-2026-0346
(client session values print TLS secrets throughDebug), -0347 (application data
sent to the outer server after an ECH rejection), -0348 (Brotli certificate
decompression allocating up to 1 GiB before the peer is authenticated) and -0349 (an
unauthenticated ServerHello downgrading later connections to classical key exchange).
Reachability here is limited and was read, not tested: the lockfile has nobrotli
crate and nothing in the workspace enables ECH. -
duckdb/libduckdb-sys1.10506.0 → 1.10506.1, which takes the build-timezip
from 6.0.0 to 9.0.5. Three RustSec advisories againstzip6.0.0 (RUSTSEC-2026-0367,
-0368, -0369:ZipArchive::extractandZipStreamReader::extractcreating symlinks and
applying permissions outside the destination, fixed in 9.0.0) failedcargo deny,
cargo auditand OSV-Scanner.zipis a build-dependency oflibduckdb-sysonly, and
the call that extracts an archive sits in anot(feature = "bundled")module this
project's builds never compile, so nothing here was exploitable and the binary never
contained the crate. The engine is unchanged (the10506component is DuckDB 1.5.6, so
the extension pins hold). -
BIRDNET_CORS_ALLOWED_ORIGINS=*no longer aborts the service at boot. tower-http
panics on a wildcard and the workspace builds withpanic = "abort", so the service
restarted into the same panic. The entry is dropped with anerrorlog, valid entries
are kept, and with none left the policy is same-origin only. A wildcard is not turned
into "any origin".
Upgrade notes
- Two microphones at one site now each record the same bird: the duplicate-prediction
interval is keyed by source as well as species. A one-source station is unchanged.
What a station that upgrades from 0.17.1 notices without doing anything; each item
is described under Changed or Fixed.
- Models and detection. A new install runs BirdNET V2.4; an existing station
never changes model.CONFIDENCEdefaults to 0.70 (was 0.75) for a station that does
not set it, so more detections are stored; the human-sound rule (PRIVACY_RANK10)
is on for a station that sets neitherPRIVACY_RANKnorPRIVACY_THRESHOLD, so the
windows around speech are not stored. On V2.4,SENSITIVITYnow
means what it means in BirdNET-Pi (it was inverted). Recordings the model's rate does not
match (V2.4 on 44.1 or 22.05 kHz sources; V3.0 on any recording not already 32 kHz,
which includes every 48 kHz one) used to reach the model
up to one sample early and no longer do, which can move a borderline detection across
the threshold; V2.4 on a 48 kHz recorder is unchanged. - Notifications.
APPRISE_TRIGGER=offnow means off (it was read as "each").
Template values that used to arrive blank or as0.000000($latitude,$reason,
$listenurland others) are now filled in. - Data. Migrations 53 to 56 run at the first start (v0.17.1 has 52); 54 builds an index, once
(slower on a Pi). Stored hourly sound-level buckets keep their old readings, so a
top band's history steps once. - HTTPS. A local CA made by
--tls-mode self-signedis replaced at the next start
(a name constraint was added); devices that imported it importlocal-ca.crtonce. - Sign-in. With
CADDY_PWDset, a password changed on the Accounts page now
survives a restart; editCADDY_PWDto replace it. - API.
/api/v2/detectionsappliesdatetogether withspecies; webhook
requests no longer useHTTP(S)_PROXY; the alert-rule export is version 3. - Docker and tools.
--build-arg RUST_VERSIONandDEBIAN_CODENAMEno longer
exist;--freq-shift-hzneeds ffmpeg 4.3 or newer. - Known limitation. On a V3.0 station the human-sound rule masked 3.9 % of the
measured windows against 37.0 % on V2.4 (docs/book/guides/parity.md); do not rely
on it for privacy there.
Changed
- A new install runs BirdNET V2.4, not the V3.0 preview.
install.shand a new
Docker volume install V2.4 and its v2 range filter (birdnet-behavior --install-model,
SHA-256 checked);--classifier birdnet-v3orBIRDNET_CLASSIFIER=birdnet-v3chooses
the preview. A running station never changes model: an existingbirdnet.conf
decides, and a Docker volume holding the V3.0 file keeps it unless
BIRDNET_CLASSIFIERis set. Checked with the installer and entrypoint suites on stub
binaries; not run: a container, a realsystemd, or an upgrade from v0.17.1. CONFIDENCEdefaults to 0.70, and the human-sound rule is on. 0.70 is
BirdNET-Pi's own (it was 0.75).PRIVACY_RANK(--privacy-rank, default 10,0is
off) is BirdNET-Pi's rule: a window and the windows either side are dropped when a
human class is among the model's 10 best-scoring classes. It is on after an upgrade
for a station that sets neitherPRIVACY_RANKnorPRIVACY_THRESHOLD, and works
besidePRIVACY_THRESHOLD(off by default; either suppresses). Against BirdNET-Pi's
masks with the real V2.4, 13 of 14 clips agree window for window (the 14th is a
synthetic clip's digital-silence tail). On V3.0 the rule does not protect: over the
same 91 clips it masks 3.9 % of windows against 37.0 % on V2.4, and 0 of 31 windows of
five speech recordings; nothing in this project measured a setting that does
(docs/book/guides/parity.md).--doctorwarns about a BirdNET-Pi
PRIVACY_THRESHOLDabove 0.5: the key is shared and the scale is not (BirdNET-Pi: a
percentage of the species list; here: the model's confidence for a human sound).- V2.4 species carry BirdNET-Pi's English names. The label file birdnet-go publishes
names 460 of V2.4's 6,522 classes differently ("Common Magpie" for BirdNET-Pi's
"Eurasian Magpie"), so an imported history would hold one species under two names.
--install-model birdnet-v24writes BirdNET-Pi's names, built into the binary and
checked against a digest; a station that installed the old file keeps it until the
command is run again by hand.--install-modelalso no longer fetches a file you
already have: a file under the right name whose SHA-256 matches the pinned one is
skipped (before, every run downloaded again: 541 MB for V3.0, 84 MB for V2.4); a
truncated or substituted file is fetched again. APPRISE_TRIGGER=offmeans off. It used to be read as "each". A station that set
offand was notified about every detection should expect silence now. The settings
form refuses a trigger word it does not know. Hints that described another setting
are corrected (PURGE_THRESHOLDis a percent of disk, the notification cooldown,
the names lists, the templates, and the avahi line, which claimed the station was
"discoverable as birdnet.local" when all it had done was write a service file).- The classifier no longer spin-waits between chunks, and the analytics engine
leaves it its cores. ONNX Runtime's worker threads busy-waited after every
inference; off, on a 4-vCPU x86_64 host with the BirdNET v2.4 INT8 model, 52 to 54 ms
of CPU per inference instead of 93 to 98 ms, for 3 to 4 ms more wall time. DuckDB used
every core whatever the process's affinity; it now uses the CPUs available less the
classifier's two inference threads, at least one (2 on a Pi 4);
BIRDNET_DUCKDB_THREADSoverrides. On a 4-core host with two busy loops standing in
for inference, two threads halved the slowest dashboard query against one without
slowing the competing load more than one thread did. Not measured on a Raspberry Pi. - ONNX Runtime 1.31 (
ort2.0.0-rc.14, was rc.13 with 1.28;Cargo.locknow holds
rc.15). Every session is
created under one process-wide environment (ortallows one per process). Scores are
unchanged: the top 25 species of every 3-second chunk of the test recording match the
previous runtime to nine decimal places (x86_64, the BirdNET+ V3.0 model; not measured
on a Pi).scripts/setup-onnxruntime.shseeds the runtime for theort-sysversion in
Cargo.lock(it took the first one unpacked, which after an upgrade was the old one). - DuckDB 1.5.5 → 1.5.6 (
duckdb/libduckdb-sys1.10505.0 → 1.10506.0) to pick up
duckdb-behavioralv0.10.0 and the embedded ICU for 1.5.6. v0.10.0 is built on
DuckDB's stable C API: its footer carries ABIC_STRUCTand a C API version
(v1.2.0) where v0.9.1 carried the DuckDB version, so the build script and the runtime
check, which compared that field to the engine's version, would have reported a correct
embed as a mismatch on every start. They now apply DuckDB's own per-ABI rule (exact
version forCPP, same major and no newer than the engine's C API forC_STRUCT);
/api/v2/analytics/statusreports the embed'sabi. rubato4 → 5 andargon20.5 → 0.6 (withpassword-hash0.6 and
audioadapter-buffers5). A password hash and an encrypted backup written by the 0.5
line are frozen as tests and pass on 0.6. Session ids, CSP nonces and share keys draw
from onerandom::fillovergetrandom. htmx 2.0.4 → 2.0.11, checked against the
registry's integrity hash; the 2.0.5 to 2.0.11 changelog was read for what this
interface depends on (the default that never swaps a 4xx or 5xx body is unchanged).
THIRD_PARTY_LICENSES.txtis regenerated (it still listedaudio-core, which rubato 5
no longer uses).- The design system is tokens, held by gates, and the shell has depth.
app.css
gained the scales it lacked (additive; no existing token was renamed or re-valued except
the elevation and fluid heading sizes): two seven-step sequential ramps, a
seven-step diverging ramp, data ink and mark tokens for charts, layered surfaces and
page washes, a warm/cool-tinted shadow family, the rest of the radius, spacing, type,
line-height, motion (a spring curve,--stagger,--lift/--press-scalezeroed
under reduced motion), layer, focus-ring and density tokens: 241 tokens in the light
block (was 135) and 90 dark overrides (was 51), every one documented in
docs/book/reference/design-tokens.md, whose tables and counts are generated from the
stylesheet (tools/visual-qa/token-doc.mjs --check). Raw font sizes (a ladder of 15
--fs-*tokens), spacing, radius, duration, weight, hairline and colour literals became
tokens, checked with the computed-style snapshot (style-snapshot.mjs). Badge text
uses--on-fill(it was white on bright amber in dark mode). The shell: elevation is
mixed from a tint instead of pure black; the top bar and phone tab bar are glass;
buttons, chips and segmented controls press; fields are cut in; toasts, dialogs and
the command palette arrive with a spring; native parts follow the page's theme (a dark
page no longer opens a white select menu); page titles scale to 56 px and the hero to
72 px (fluid); navigation cross-fades (View Transitions); all of it is off under
prefers-reduced-motionand the operator's Motion = Reduced.prefers-contrast: more
does what the High contrast setting does,forced-colorskeeps bars and card edges,
and the browser'stheme-colorbar follows the theme. Tints mixed in OKLab keep their
hue (OKLCH walked a 16 % amber to pink). Gates:tools/visual-qa/chart-ramps.mjsand
surface-layers.mjs(no browser; ramps monotone in lightness, diverging poles apart
under protanopia and deuteranopia, page washes leaving text 4.5:1, focus ring 3:1),
rule 5 oftests/design_tokens_are_the_only_literals.rs(a raw shadow, radius,
duration, curve, layer or on-ladder gap fails),tests/design_tokens_doc_matches_the_stylesheet.rs,
andtools/visual-qa/layout.mjs(text drawn over text or a control, columns squeezed to
nothing, controls escaping their card, text under 9 px, at both viewports and themes; a
CI step ina11y.yml, withlayout-selftest.mjsholding both directions: it reports a
painted overlap and not text an ancestor clips). Found by the same pass and fixed: form
hints struck through by the input above them, the Data quality histograms unstyled
after being folded into the Station tab, the empty Access, audit and sessions states
squeezed into a 92 px column, legacy admin forms without the stylesheet's control
style, the chord diagram's names upside down, 40 px touch targets and 16 px fields on
coarse pointers, andprint.cssprinting absolute link addresses only. Not checked:
Firefox and Safari, a screen reader, the compact and comfy density settings, real Pi
hardware. Known and left: the/admin/*nav wraps to two rows at 1180 px, Quarantine uses four
button styles in one row, the patterns activity grid scrolls sideways on a phone, and the
dark-theme chart fills are a deliberate bright boost. - One rule for confidence, with a key. Five rules drew the same detection in
different colours (82 % was amber on the Today feed and green on the Review page).
One function now grades every surface: 90 % and up is very sure, below the
station's cut-off plus ten points is borderline, between is sure, so the edges
follow the cut-off the station chose (at 70 %: 70 to 79 amber, 80 to 89 neutral, 90+
green). The tiers are--conf-*tokens (tokens.mjsgrades 3:1 for a fill against
its track and 4.5:1 for pill text), and a key is printed above each list of bars. On a
phone the Today feed's names wrap instead of ending in an ellipsis and the confidence
is shown. - The header badge and the Health page read one list. The badge graded three things
and the page a dozen, so a station with two cameras stalled read a green Healthy in
the header over a page saying Needs attention. Both build one issue list now; one
issue names itself ("Source stopped"), two or more read "Needs attention", and a
damaged or unreadable database is red. The badge reads the integrity verdict the daily
pass recorded and caches its twodfreadings for 25 s; it never runs the full check. - The charts are redrawn on a shared kit, and their labels stay readable on a phone.
static/chart-kit.jsgives every chart a tooltip for a pointer, a finger and the
keyboard (one tab stop, arrow keys, anaria-livereading), puts one series in the
foreground with the rest at--chart-dim, and plays an entrance that reduced motion
switches off. On it: the activity streamgraph (every other species as one more band,
so the thickness is the day's real total), Today's hourly strip (night shaded, a now
marker, the three busiest species in each hour's tooltip), the hour-by-weekday grid
(average per day), detections by hour, the Trends charts (the 7-day average over thin
daily columns, the 30 days before as a ghost, the busiest and quietest day named), a
53-week calendar on the Year in review and on every species page, the weekly report, the
life-list curve and the confidence distribution. A day the station was not recording
(from the recording-effort ledger) is hatched and says so rather than drawing a silent
day; an hour or day still to come says "Not yet". Today's hour totals come from the
database, not from counting the first 1,000 rows of the day. Who sings together is a
chord diagram whose species stand in the order that crosses the fewest ribbons (a
staircase matrix on a phone); the dawn rings put each hour's count at the middle of the
hour (they were half an hour early) with a sky strip from the station's coordinates; the
migration ridgeline is coloured by the season a species peaks in; the Behavior tab draws
bursts of singing, a return-rate ledger by residency class, a before-and-after flow and
the morning running order. On When active, a soundscape terrain: the last 30, 90 or 365
days by hour, as a flat grid, or raised in 3D on request (three.js 0.186.1, tree-shaken
and vendored understatic/vendor/three/with its hash and rebuild recipe, loaded only
when 3D is asked for, about 134 kB gzipped). The categorical palette is eight colours per theme graded by
arithmetic (the old one had two outside sRGB), the heat ramp darkens with more in both
themes, and a drawn empty state replaces a blank migration card. Charts are fixed-canvas
SVGs scaled to their card, so a label authored at 8 px rendered as small as 2.7 px on a
390 px screen while axe called it clean:static/chart-text.jsraises any label that
would render under 9.5 px (by at most 4×), abbreviates one that then collides and hides
one that still does (data-no-text-fitopts out). Gates:tools/visual-qa/legibility.mjs
(with the fitter withheld, 17 of 78 route-and-viewport combinations failed; with it,
none),charts.mjs(hover, keyboard, dim, reduced motion, no replay, theme),
charts-edge.mjs(an empty station, one detection, sixty species with 70-character
names, 1.6 million detections in an hour, a 365-day range),relational.mjs;
docs/design/charts.mdstates the rules and how to add a chart. The screenshot fixture
is now a year with weather, seasons, flocks and days the station was off. - The admin pages and the live-log viewer use the site's own layout. They were
standalone documents with a different header, no tab bar, no breadcrumbs and no theme
toggle on a phone. They render inside the shared layout, with the admin sections as a
one-line strip that scrolls sideways on a narrow screen; Changelog, Notification
Center, Search, Quarantine and Detection Reviews also have a home tab. Quarantine says
why in plain words ("Unlikely here this time of year", "Below the bar set for this
species", with "N% likely here this week" where the range model supplied a figure). A
network source is labelled Network stream rather than "Network camera", and the
Capture tab's section is Cameras and streams. Thirteen rules authored at 8 to
9.5 px were raised to 10 px. GET /api/v2/languagessays what a station can show. Each language carries
native_name,activeandcoverage(namedofofspecies in the installed
classifier; null for English or without a label file), and the body carries the
activecode. Before, it listed all 36 languages as available.- The species list fits a phone. Below 520 px its Detections and Avg-confidence
columns started past the card's edge behind a sideways scroll that nothing said was
there; it is a wrapping table again, without the sparkline or the bar (the percentage
stays) and, below 400 px, without the rank. A name breaks inside a word only when it
must (a German compound alone made it 314 px in a 304 px card). - The manual's
/dev/is built frommain, and each release links its own manual.
On a tag push/dev/was built from the release tag; it now comes fromDOCS_DEV_REF
(defaultorigin/main). A stable release's notes link its/vX.Y.Z/manual. Every
crate tarball carriesLICENSE,LICENSE-UPSTREAMandNOTICE
(scripts/check-crate-licences.py). The documented source install is
cargo install birdnet-behavior --locked(ortis required as the pre-release
^2.0.0-rc.14). Nothing is published; crates.io admits five new crates at once, then
one per ten minutes (RELEASING.md). - The crates package for crates.io, and a job keeps them that way. Nothing has been
published and the decision is the maintainer's (the licence is CC BY-NC-SA 4.0).
cargo package --workspacefailed on the first crate that depends on another (the
eight internal path dependencies had noversion; they have one now) and found two
real defects: the web crateinclude_str!edCHANGELOG.mdfrom outside its own
directory (it reads a symlink inside the crate now), and the rootbuild.rsrendered
the manual into the source tree, whichcargo publishverification rejects (it does
nothing in a package now). The root package is 826 KiB (it was 11.8 MiB, over
crates.io's 10 MiB limit), each library crate has a README saying it is internal, and
the two crates whose default features compile the bundled DuckDB build their docs
without it on docs.rs.cargo package --workspace --no-default-features(which builds
each crate from its tarball) passes for all nine; thecrates are publishablejob and
the releasevalidatejob run the fast form on every pull request and tag.
cargo-denydenies wildcard dependencies. Acargo installbuild has no in-app
/helpmanual and embeds neither DuckDB extension (they are fetched on first use into
~/.duckdb). Not done: the bundled-DuckDB build of the packaged crates, docs.rs and
cargo publishitself. - Release archives and the container image carry the licence notices the binary's
third-party code requires. The binary statically links 389 third-party Rust packages
(twelve,symphonia, under MPL-2.0), DuckDB with its bundled libraries and ONNX
Runtime, and serves four font families (SIL OFL 1.1), htmx and three.js from memory;
none of their notices travelled with it.NOTICEsays in prose what is included under
which terms, andTHIRD_PARTY_LICENSES.txt(generated by
scripts/gen-third-party-licenses.pyfromCargo.lockandthird_party/) reproduces
their licence texts, ONNX Runtime's own notices and DuckDB's bundled-library and ICU
notices. Both are in every archive and in the image at
/usr/share/doc/birdnet-behavior/. A package declaring MIT without publishing a
licence file is written out under the standard MIT text; any other licence
without a file fails the generator. Thethird-party licence noticesjob and the
releasevalidatejob fail when the committed file is stale or a vendored text is for
a different ONNX Runtime, DuckDB or htmx than the build links.NOTICEalso records
that the BirdNET+ V3.0 preview 3 model's Zenodo record says CC BY-SA 4.0 and that
tests/testdata/Pica_pica_30s.wavis byte-identical to the recording in BirdNET-Pi's
tests/testdata/. - Releases and CI are stricter, and pinned. A release refuses a lightweight tag, a
tag off the default branch and an undated changelog heading (21 of the
repository's 24 tag refs are bare commits althoughRELEASING.mdprescribes
git tag -a, read 2026-10-21);install.sh,uninstall.shandSHA256SUMSare attested like the
archives andverify-releaserunsgh attestation verifyon every asset. Release and
Docker builds fail (after three retries) when a DuckDB extension cannot be fetched,
instead of shipping a binary that needs the network at first run (the 0.13.1 fault);
--build-arg BEHAVIORAL_EXTENSION_OPTIONAL=1builds without them. Release and upgrade
builds pass--locked, and the release workflow first runscargo update --workspace --locked, which fails in seconds on a lock that disagrees with a manifest. Every
uses:line of every workflow (133 of 133 on 2026-10-21) is pinned by commit, including
dtolnay/rust-toolchainwith an explicittoolchain:input; eleven actions moved to
their current releases (among themactions/checkout7.0.1); every tool CI installs
names a version (cargo-audit27.1.0,cargo-mutants0.9.1,cargo-llvm-cov0.22.2,
pyyaml6.0.3,cargo-chef0.1.78), the browser-QA job runsnpm ciagainst the
committed lockfile, and the Dockerfile's twoFROMlines namerust:1.95-slim-trixie
anddebian:trixie-slimby manifest digest. Breaking for anyone who built the image
with--build-arg RUST_VERSION=…orDEBIAN_CODENAME=…: those arguments no longer
exist; change theFROMline.scripts/check-ci-config.pyfails on an unpinned
install, a range, a base image without a digest, a builder whose Rust is not
rust-version, a workflow naming a lockfile git does not track, and a fuzz target
missing from the workflow's matrix. - New workflows.
fuzz.ymlruns the twofuzz/harnesses (audio decoder, label
parser) weekly (10 minutes a target) and on pull requests that touch them (60 seconds),
with-Obecause the release build has no overflow checks; the first local run
crasheddecode_audioon a 37-byte WAV (an arithmetic overflow in
symphonia-format-riff0.6.1, computed only to log a block-align mismatch), and the
input is kept infuzz/known/.codeql.yml(security-extendedover Rust, the
workflows, the served JavaScript and the Python gate scripts),osv.yml(fails on a
finding inCargo.lockor the browser-QA lockfile) andscorecard.ymlare adapted
from the maintainer's a2a-rust workflows. OSV reported no record for the 554 registry
packages inCargo.lockwhen it was queried, andnpm auditfound nothing in the QA
lockfile.
Fixed
-
Every page fits a 320 px phone, and the setup wizard fits a 360 px one. The wizard's
stepper was a fixed 369 px, so on a 360 px phone (the commonest Android width) the whole
wizard scrolled sideways by 23 px, and by 8 px on a 375 px iPhone SE; the connectors now
take what is left. At 320 px, Today's source menu no longer sits under its Listen-live
link, the live feed's play buttons no longer push the page sideways, the Live view's
source menu is no longer cut off, and the Capture Remove buttons, the Data selects and the
Access password field stay inside their cards. Below 341 px a card takes 16 px of padding
instead of 24 px. The layout gate (tools/visual-qa/layout.mjs) now grades 1440, 390, 360
and 320 px by default (it graded 1440 and 390), and reports a control cut off by an
overflow: hiddenancestor, which it passed before. Checked in Chromium, Firefox and
WebKit; not on a real phone. -
A blank number in a form no longer fails silently. An optional number left blank
(audio-source sample rate, migration confirm, quarantine pager, alert-rule hours)
answered a 422 that htmx never shows, so the click appeared to do nothing. Every form
handler now takesHtmxForm: blank means none, and a value that is present and not a
number is an error toast naming the field and never repeating the value. A client that
is not htmx still gets a 4xx, now as JSON. The migration clock offset used to readabc
as "none". Testing an alert rule deleted in another tab, and a BirdNET-Pi upload or
settings-file preview that stops arriving or passes the size limit, now say so on the
page. A new gate,an_htmx_form_cannot_fail_silently, fails on a rawForm<T>and
drives every form handler with the browser's own body. Still silent to htmx: a 403 from
the cross-site check or the viewer role, and a 429 from the rate limiter. -
The service worker is dormant, and says so. It was registered at an address the
server does not serve and with a scope that would have let it keep pages that differ per
visitor, so it never ran. It is now registered at<base>/static/sw.jswith scope
/static/, no longer precaches/, and its header says what it does and does not do. A
gate (sw-state.mjs) checks this in Chromium, Firefox and WebKit. -
The live log answers at once. The stream sends a comment the moment it opens, so
Firefox shows "Connected" immediately instead of after the first log line. -
Settings Location: moving the coordinates clears a stale time zone, so a station
moved to another place is not left on the old zone. -
A long identifier no longer widens the page on a phone. The hint under Notification
Cooldown quotesAPPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES, one 385 px
word in a 292 px box, which grew/station/alertsand/admin/settingsto 434 px on a
390 px phone and dragged the fixed tab bar (and its last tab) out of view. Hints, table
cells, code chips and file names now wrap anywhere. The layout gate gained the check it
lacked (a page wider than the screen, named by the element that reaches furthest) and
went red on the old stylesheet before the fix. -
Form controls look the same in every browser. A placeholder had no style of its own and
measured 2.35:1 in WebKit (Safari's engine), 3.8:1 in Firefox and 4.08:1 in Chromium's dark theme; it is now
the quiet-text colour (at least 5.6:1) and the token gate grades it on the real fields. The
Listen sliders are one drawing in Chromium, Firefox and WebKit, and number fields (the
alert rules' hour start and end) lose the permanent stepper arrows in Firefox and WebKit
(the arrow keys still step). -
Phone layouts. The species page's 14-day chart is drawn twice, a wide one for a card
and a narrow, taller one for a phone, so its labels no longer sit on the baseline, the
last label is not cut to "2. 0…" and the average is not written across the busiest bar;
the thresholds and minimum-interval tables fit a phone (their Remove buttons were cut off
at the card edge) and the name field takes a row of its own; the Companions table's second
heading is no longer clipped; "Even out clip volume" shows its chosen option; the Data
page's snapshot rows, Export buttons and "Save settings" no longer wrap mid-word; Year in
review's Milestones card stops stretching to the height of the leaderboard; the setup
wizard's sticky stepper is transparent until the page scrolls under it; the 3D terrain is
fitted to its canvas on any shape of screen (the near edge ran off the bottom of a desktop
one and the mesh was cropped on a phone), and its axis labels sit on a plate so a ridge
does not take their contrast. -
The published manual passes axe at every version. The in-text link underline lived in
custom.css, which the versioned build reads from each tag, so the root andv0.17.1
manuals (a release made before the rule) failedlink-in-text-block16 times; it is in
a11y.css, which every tree gets. -
Visual-QA gates that were wrong in both directions. The sweep reported a stuck
"Loading…" on/patternsin all four states for text inside a closed disclosure (the
placeholder now ships empty and the script writes it when asked; the sweep asks the browser
whether it is visible, with a self-test that the check still bites on a visible one), and
interactions.mjsfailed at some hours of the day because the fixture seeded today only
up to the clock: today now always holds at least 24 detections, andBNB_FIXTURE_NOW
runs the fixture at another time of day. -
The manual and the product's own sentences name the screens the Settings section has.
README, five manual pages, the.env.examplecomment and the settings page's own hint
still sent readers to "Station → Data", "Settings → Home Assistant & MQTT", "Settings →
Online services" and "Station → Access → Secure connection"; they now read
"Settings → Data", "Settings → Alerts → Home Assistant & MQTT", "Settings → General →
Online services" and "Settings → Access → Secure connection". The gate that checks them
no longer reads the word "Settings" inside a card called "Detection Settings" as the start
of a second path (it had reported 19 correct paths as wrong). -
Previewing the protected clips of a BirdNET-Pi import no longer takes the one writer
connection, and the per-species minimum interval, the imported-settings apply, the clip
lock and the learned-threshold reset are listed in the writer ledger with their reasons. -
Importing BirdNET-Pi clips is safer. A move (
--clips-mode move) is refused when this
station's retention would delete the clips; copies keep their age; every copy is synced to
disk, and so is its directory, before the original is removed; only one import runs at a
time and a crashed import or classifier-install worker ends as Failed instead of blocking
every later one; applying an import writes the Apprise file only after the settings are
saved. BirdNET-Pi's protected clips arrive locked, so this station's cleanup keeps them. -
The duplicate-prediction interval is per source. A call at a second camera is no longer
dropped as a repeat of the first; a one-source station is unchanged, and an earlier
recording arriving late (a restart backlog) no longer rewinds the gate. -
Start-up notices say when
APPRISE_TRIGGER=off|none|never,RECORDING_LENGTHor
FULL_DISK=keepin an existing config now takes effect, and warn thatFULL_DISK=keep
is ignored when--watch-diris the recordings directory. The held-recording message and
Station Health say recording resumes 2 points below the purge threshold, and a raised
threshold applies after a restart. -
Smaller: a sign-in attempt made while users are unreadable no longer clears an address's
failure count; a failed restart signal no longer leaves a re-exec pending; the dashboard
picture is streamed and cannot hang on a swapped-in FIFO; spectrogram ETags include a
renderer revision; a learned-threshold reset matches the species whatever its case and is
applied while the station is idle;MQTT_TLS,MQTT_RETAINandMQTT_HA_DISCOVERY
accept1,yesandon; a viewer's navigation omits Diagnostics. -
Supply chain (Scorecard):
docker.ymlhas a read-only workflow token with writes granted
per job;mutation.ymlinstalls its one Python package from a hashed requirements file;
upgrade-e2e.shno longer pipescurlintopython3;certifiis 2026.10.19 in the tool
requirements (an advisory) andosv.ymlnow scans those files too. -
A viewer is no longer offered the Diagnostics link. The viewer deny list refuses
/admin/doctor, so the admin nav's Diagnostics entry and the classifier panel's
"Diagnostics says why" link led a read-only account to a403. The admin gate now marks
a viewer's response and the nav row leaves the entry out (the rest of the nav is
untouched); the panel says an administrator can see why instead of linking. Gates:
a_viewer_is_not_offered_the_diagnostics_linkand the nav, gate and panel unit tests.
The Station Health page and the Today checklist still name the page for everyone.
What the model hears and what is stored
- A recording that is not 48 kHz no longer reaches the model a fraction of a sample
early. The resampling library delays its output by128 × ratio − 1samples and the
code removed only the whole-sample part, so every recording whose rate is not 48 kHz
arrived early by1 − frac(128 × ratio)samples: 0.69 at 44.1 kHz, 0.38 at 22.05 kHz,
0.67 for 48 to 32 kHz (the V3.0 model's rate) and 1.0 for 32 to 48 kHz. The delay is
now made a whole number and removed, and the output length is computed in integers
(floating point gave 96,001 samples for 88,200 at 44.1 to 48 kHz). Against
BirdNET-Pi's own resampler the model's input differed by 28 % RMS on a quiet 44.1 kHz
clip and 19 % on a spoken-word one; it now differs by 0.16 % RMS in the median over
100 windows from 40 clips, at most 2.8 % (docs/book/guides/parity.md). In stored
detections: a borderline great tit in BirdNET-Pi's 44.1 kHz test clip (0.7113 there,
0.6564 here) fell under the 0.7 bar and was missing, so the first set stored 11 of
BirdNET-Pi's 12 rows and stores 12 now; the 91 rows both stored differ in confidence by
at most 0.0067. V2.4 on a recorder that writes 48 kHz is untouched (a same-rate input
is returned unchanged). Affected: V2.4 on any other rate (44.1 and 22.05 kHz, RTSP
streams, uploaded files) and V3.0 on anything that is not already 32 kHz, which includes
every 48 kHz recording. Detections already stored are not rewritten. Gates:
a_tone_comes_back_with_no_fractional_sample_delay(11 rate pairs) and
tests/resampler_matches_resampy_parity.rs, against resampy's own output. - Every resampled recording also ran late and short (an earlier, larger fault in
the same function): the start-up delay was left in, so any converted recording
(every default-install file, 48 → 32 kHz for V3.0; any 44.1 kHz microphone) was
128 × ratiosamples late (+138 at 44.1 → 48 kHz, +85 at 48 → 32) and lost that much
from its end. Nowprocess_all, lag under one sample. SENSITIVITYmeant the opposite of BirdNET-Pi's on a V2.4 model. It was applied as
the sigmoid slope itself (default 1.25); BirdNET-Pi and BirdNET-Analyzer use
clamp(2 − S, 0.5, 1.5)(default 0.75). A raw score of 3.12 stored 0.980 here and
0.912 there. It now maps as BirdNET-Pi does (inference::model::logit_slope). This
changes V2.4 results only: the bundled V3.0 ignores the setting. birdnet-go's setting
is the slope itself; enter2 − valueto carry one across.- A trailing remainder under 1.5 s is dropped, as BirdNET-Pi drops it. It was kept
down to a quarter window (0.75 s), so a 16 s file gave a sixth chunk of one second of
audio and two of silence that BirdNET-Pi never analyses. Tested against a transcription
ofsplitSignalover six overlaps and 155 lengths. - A clip cut from a 16-bit recording was not the recording's samples. The decoder
reads 16-bit audio asvalue / 32768and the clip writer scaled back by 32767, so
32,703 of the 65,536 values came back one step smaller (and-32768could not be
written). The writer scales by 32768, rounds and saturates. birdnet-go made the same
change on 2026-10-15 (a122b6b). - A model output of infinity was a 100 % detection, and a NaN from the occurrence
model let a species through. Both classifier output mappings turned+infinto
exactly 1.0; a non-finite score is now never a detection, a non-finite human-class
score masks the clip, and the first is logged. The occurrence filter skipped a species
only whenscore < threshold, which a NaN never is, so an all-NaN geomodel output
admitted every species in silence; it is now a reported run-time failure. - The frequency shift is a shift in hertz.
--freq-shift-hzwas a ratio: with
ffmpeg 6.1.1 an 8 kHz tone at -3000 came out at 7,500 Hz (not 5,000) and a 3.0 s clip
at 3.2 s. Saved clips and the live stream useafreqshift(ffmpeg 4.3 or newer): 8 kHz
at -3000 is 5,000 Hz and the length is unchanged, measured on audio. The sox fallback
is gone (sox cannot shift by hertz); if the shift fails the clip is saved unshifted and
a warning says so. A shifted WAV also lost its species, confidence and date tags; it
keeps them. - FLAC, MP3 and Ogg clips carried no tags. Species, confidence and date were written
only when a clip stayed a WAV. The WAV is tagged before conversion and ffmpeg carries
the tags into the format's own (Vorbis comments, ID3); the sox fallback does not. - One failing classifier silenced the rest. On a station running more than one
classifier, an error from any of them failed the whole file, so a broken second model
stopped the primary from recording anything. A window fails only when none of its
classifiers answers. - A bird that has lived here all year is not "new this season" on the first day of the
next one (P-20).classifycalled the first detection after a season boundary new
even when the bird had been recorded the day before; a 7-day carry-over
(SEASON_CARRYOVER_DAYS, birdnet-go's figure) now applies.charts.rshas its own
definition of "new this season", which this does not change. - The sound-level meter's top bands did not measure the band they named. The band
filter's bandwidth was pre-warped for the bilinear transform but its edges were not,
so near Nyquist one edge landed too low and the other too high: a unit sine at the
20 kHz band's two edges at 48 kHz read −4.38 and −8.91 dB where −6.02 is right, and
the same shape shows at every sample rate. The edges are now taken through
tan(πf/fs); both edges measure −3.01 dB to 0.003 dB in the top bands of 48, 44.1, 32
and 22.05 kHz. The price: a tone at the nominal centre of the 20 kHz band at 48 kHz
reads 0.34 dB low. A band whose upper edge reaches Nyquist is refused, so a 22.05 kHz
station keeps 26 bands, not 27 (48 kHz: 30, 44.1 kHz: 29, 32 kHz: 28; counted by
tests/soundlevel_accuracy.rs). White noise reads about 0.5 dB high across the bands
by the shape of the passband (stated in the filter's docs).SoundLevelMeter::push
returns only the newest of several completed intervals, as it always did, now
documented;push_withreports every one. Stored hourly buckets keep their old
readings, so a top band's history steps once at the upgrade.
A failed read is not a fact
- A failed read is no longer printed as "nothing" or as good news. A parse of the
tree (tests/a_failed_read_is_not_defaulted_into_a_fact.rs, one row per site in
tests/data/read_default_sweep.tsv) classifies every read whose error is defaulted in
its own closure; the real ones now say they could not look. API:/api/v2/species/tracking
and/api/v2/soundlevelanswered 200 with nothing (both 500 now),/api/v2/system/jobs
answered an empty list, which reads as "nothing is due" on exactly the station that has a
problem (500 now),/api/v2/analytics/statusreported an unreadable count as0
(nullnow), and/streamanswers 503 where the table is unreadable (404 only for a
missing row). Pages: the suspect-species report ("Nothing looks suspicious") and the
dawn-sequence card ("Not enough dawn activity yet"); the species page's best recording,
per-species thresholds, the last backup, the live source picker, the Today pill, review
strip and outage banner, the Recordings grid, the live feed's "first ever" badge,
Station Health's microphone panel, share links, the Migration cards, the merged-history
note, the account and session lists; the data-quality panels ("Database quality looks
good!"), Species storage ("0 B across 0 clips"), the filter-test page, both rule
lists, the notification history, the image blacklist and the import history; the
Capture tab ("0 mics, 0 streams" with two add forms) and the Listen default; and
sign-in, which blamed the password for an unreadable users table. A blacklisted image is
no longer served when the blacklist cannot be read. A user or session change that went
through is reported as done even if the list cannot be redrawn, a panicked Station tab
says so instead of rendering blank, and a failed approval no longer quotes the
database's error. - A refused bulk species action no longer reports "0 removed" and deletes the audio.
The clips handler marked rows reclaimed, defaulted a failure to zero and went on to
unlink every file of the species. It stops at the first refusal, answers 200 with a toast
(htmx discards a 5xx body) saying only what is certain, and writes no audit row for a
change that did not happen. An unreadable species list is no longer replaced by the
one name just added: the include, exclude and whitelist handlers read the stored list
with.ok(), so forty excluded species became one. Only a list that was never written
is empty; the handlers say so when a list cannot be read or written. The daemon still
treats an unreadable list as empty (logged atwarn). - BirdWeather is never sent a position the station does not have. A station with a
token and no (or an unparseable)LATITUDE/LONGITUDEbuilt its client at 0°, 0° and
posted every detection to a public map in the Gulf of Guinea. The client refuses a
position that is missing, not finite, off the globe or exactly 0, 0; one start-up
warning says which value is wrong;--doctorhas a "BirdWeather location" check; and
uploads an earlier version parked at 0, 0 are dropped with a warning. A BirdWeather
token pasted with a trailing newline or space failed every upload: it is trimmed on
save and when the client is built, a token with anything but letters and digits is
refused with a reason, and it is encoded as the single path segment it is.
Database, concurrency and speed
- A request waiting on the database no longer stalls the others. 170 places in the web
server and the daemon's background tasks read or wrote a database directly on an async
worker thread (most through a helper such as the audit-log write, which 43 admin
actions call), so while the detection writer held the database, every page, script and
live socket on that thread waited too (a Pi 4 has four). All of them run on the
blocking pool (AppState::run_blocking), startup's ten one-off reads excepted. The
gate also coversstd::fsnow: 42 calls in 28 async functions moved totokio::fsor
the blocking pool (avatars, recordings, spectrograms, HLS, backups, TLS reload, the
maintenance loop). With one worker and the database lock held, a static script was not
served within 5 s before and is served at once now. An admin change and its audit-log
row are written together, so a client that disconnects between them cannot leave a
change unrecorded. - Only writes take the writer connection now, and the analytics store has readers.
with_dbis the one SQLite connection the capture daemon's inserts share, and 223
with_db(lines in 56 files ofbirdnet-web/srctook it, so a page that only read
waited behind a detection being stored. 117 production sites that only read (found by
classifying every one) usewith_read_db(the pool of read-only connections),
including the three on every authenticated request; the session'slast_seenis
rewritten only once it is a minute old. What remains on the writer is listed with its
reason incrates/birdnet-web/tests/writer_lock_sites.txt(55 sites), and
the_writer_lock_is_for_writingfails when a new one appears. The DuckDB analytics store
had one connection behind oneMutex, so a six-second retention query held the status
badge and every chart behind it; it has two more connections for reads
(with_analytics_read,with_timeseries_read; 35 call sites moved). Not measured: the
throughput of concurrent heavy analytics queries, and Raspberry Pi behaviour. - Sixteen visitors to the public heavy pages could keep the station from answering
anything else. The RSS feeds, the four detection exports, the insights routes and the
migration cards read the whole history through the writer's connection. They read through
the pool now, and a heavy-read gate (half the pool, at least one) keeps them from taking
every reader: past it a heavy request waits for a slot (exports, insights, analytics
fragments) or is refused with503andRetry-After: 15(the three feeds, which
aggregators poll). On a 300,000-row station (debug build, four cores) with sixteen heavy
requests in flight,detections?limit=1took 38.9 s before and 0.01 s after,/species
10.5 s and 0.07 to 0.10 s. A singlerare.rssis not faster (5.2 s, then 4.6 to 4.9 s). - The rare-bird feeds and the "has this species been heard before" probes seek by species
and date. Every index that could answer led withDateor the species alone, so each
probe read every row of the species. Migration 54 addsidx_detections_species_date (Com_Name, Date)and the feed's query states its window as aDaterange too (the index
alone made the old query slower, 17 s against 3.25 s). On 300,000 rows: 3.25 s to 0.94 s
forrare_species_days = 30, and 300 s to 0.96 s forrare_species_days = 0. Cost:
0.30 s to build, +8.2 MB on a 147 MB database; a Pi builds it slower, once, at the first
start after upgrading. - The retention analysis took 74 seconds on a 300,000-detection station and held the
analytics connection the whole time. The query joined every pair of a species' active
days; it now joins only the days within the longest interval, which cannot change an
answer. 74.4 s before, 6.65 s after, 150 species rows identical. analytics/phenologyandanalytics/abundanceanswered 503 on every station (the
duckdbcrate panics when asked a prepared statement's column names before it runs; the
catch-all arm turned the panic into "analytics database not configured"). Both answer, and
they no longer truncate decimals (detections_per_hourof 0.43 reached the JSON as0,
aDECIMAL2.5 as3); FLOAT columns render at their own precision.
Analytics
- Birds heard together are no longer reported as heard in order. BirdNET stamps every
species in one audio chunk with the same instant, and the funnels and sequence patterns
counted such a pair as ordered both ways, so the dawn running order and
/analytics/funnel/patterns/sequence-countcredited orders nobody observed.
Funnels use'strict_increase'and patterns require each step strictly after the last;
detections 3 s apart still count. The neighbour card no longer reports a species heard in
the same chunk as its "next" or "previous" one. Step times on a clock-change day take each
detection's own recorded wall clock (a step heard at 03:30 on Europe/Berlin's
spring-forward day read02:30). The follow-on card never showed a prediction (it found
the latest detection withORDER BY rowidon a view, which SQLite 3.53 rejects, and the
error became "No detections yet."). - One bad row no longer takes an analytics page down. A detection whose
detected_at_utc
is NULL while its date and time still parse has no instant and forms a day group of its
own; on itbehavioralv0.10.0 returns NULL fromsequence_match/sequence_count
andsessionizea NULL session id, and each failed its whole query on decode (the
funnels reported that day as "0 steps"). Every builder that orders by the instant now
leaves such rows out. A row with an unparseable time, or no parseable date, no longer fails
the Time series sessions card and the hourly, accumulation and sessions endpoints.
AbsenceStreakdid not parse andTumblingSpecrejected the ISO dates it documents (both
public builders with no caller); both run, and every public query builder is executed by a
test. - Effort-corrected abundance divided by effort as of the last restart. Recording effort
reached the analytics store only at startup, so each week since then had too few listening
hours, or none. The last two days are re-copied after every effort sample; the copy is one
transaction (a failed copy used to leave the table empty). - Migration "Earliest vs last year" no longer prints −356 d. A station that began
recording late last year compared a resident's first date this January with its first date
last December and called the difference an arrival. The tile needs last year to have been
recorded from its start, ignores first dates in the first four weeks of a year, and says
"last year was only partly recorded" otherwise. - Year in Review mixed three periods under one year. Its masthead said "Year in review ·
2026" but the totals, species count, leaderboard and days of listening were all-time and
the busiest day the trailing 366 days. Every figure is 1 January to today. Species tied on
count in the leaderboard and the weekly report are ordered by name. - A species page told the truth only about birds with recent detections. Its "last 14
days" chart drew the 14 most recent days with a detection (a bird heard once a week
filled it with three months); it draws the last 14 calendar days and says "Not heard in the
last 14 days" when there are none. A bird this station has not heard got "Species not
found." in a bare paragraph that collapsed the stats row; it gets the four tiles, honestly
empty. When every clip of a species has been reclaimed by retention, the Best detection
panel says so instead of "No recording captured for this species yet". /api/v2/detectionsignoreddatewheneverspecieswas given. Every filter applies
together now, through the filter the search page uses;speciesalso matches a
scientific name and ignores case (as documented); an emptyspecies=means no filter;
total_countis always present and counts the filtered set.
Configuration and operation
- A password changed on the Accounts page no longer reverts at the next restart while
CADDY_PWDis set. Every start putCADDY_PWDback when it differed from the stored
hash. The station keeps a hash of theCADDY_PWDit last applied in
admin-password.appliedbeside the database: an unchangedCADDY_PWDleaves the
Accounts-page password alone and logs a warning; an edited one still replaces it. A
station upgrading from a release without the record appliesCADDY_PWDonce (so an owner
locked out by an edited config still recovers). - Notification templates sent blanks and zeros for the station's own values.
$latitude
and$longitudeprinted0.000000on every push, and$reason,$cutoff,$sens,
$overlap,$listenurl,$friendlyurland$imagewere replaced with nothing (a migrated
body ending in($reason)rendered()). Each carries what the station knows: its
coordinates (nothing for a station that has none), the confidence bar the detection
cleared, the sensitivity and overlap used, and BirdNET-Pi's$reasonwording (detection,
first time today,only seen N times in last 7d). The links useBNB_PUBLIC_URL, then
BNB_BASE_URL, thenhttp://<hostname>.local:<port>, empty when none can be worked out.
$friendlyurlis BirdNET-Pi's Markdown[Listen here](…)link. The variables are listed in
the manual for the first time, and the.env.examplecomment that saidBNB_PUBLIC_URL
"reuses"BNB_BASE_URL(it does not) is corrected. - A systemd
MemoryMax=was invisible to the memory ceiling. The ceiling was read from
/sys/fs/cgroup/memory.max, which does not exist on cgroup v2 outside a container, so
with the shipped unit'sMemoryMax=1Gthe ceiling came out as the machine's RAM and a
second classifier could be admitted that the unit then killed. The limit is found from
/proc/self/cgroup, the tightest on the path to the root, for cgroup v1 and v2. Tested
against a directory tree laid out as the kernel does; not confirmed on a Pi under cgroup v2. - Listening live to a camera ignored its transport. The live stream always asked for
TCP, so a UDP-only camera could be recorded but not listened to. It uses the source's
transport and media mode (changeable on the edit form); the add form no longer calls
auto"TCP w/ UDP fallback" (it is TCP, with no fallback). --doctorfailed every stream URL that was notrtsp://, and a failed doctor stops
the service starting. It probes each protocol's port and skips UDP and SRT. A camera that
refused the connection said why only atdebug; it is a warning.- The audit log records the page-side mutations the API's already recorded. Deleting,
locking, unlocking and relabelling from the Today list, the Clips page and the Search bulk
bar, review verdicts, quarantine approve/reject/delete, image blacklist changes,
notification prune, backup delete and the suspect-species exclude each leave a row with the
signed-in user as actor, only when the write took effect (new actions:detection.relabel,
quarantine.*,image.blacklist.*,notification.prune,data.backup.delete). - Settings hold more values to what the daemon accepts. Segment Duration (and
PUT /api/v2/settings) refuses a length outside 3 to 600 seconds, a fraction or a unit,
and its input allowed 5 to 60, which blocked saving on a station whose valid value was
120. The API runs the same checks as the page (language, BirdWeather token, recording
window, clip format, info site). A fixed recording window that does not end after it
starts is refused, since the scheduler ignores it and records all day;--doctorwarns
about one inbirdnet.conf. - Docker deleted an operator's own model.
docker/entrypoint.shchecked any
BIRDNET_MODELagainst the V3.0 digest, removed a mismatch and downloaded V3.0 under that
name, destroying a V2.4 or Perch model. Only files at the default paths are managed.
Model catalogue URLs follow no branch (perch-v2pointed at HuggingFaceresolve/main/);
it is pinned to the revision whose LFS metadata lists the same sha256 and size, and
no_catalogue_url_follows_a_branchkeeps it so. Station → Accessshows an invited viewer once; the unlinked/pages/quarantine-pending-countis gone; a
blank invite label is stored as no label; the range-filter list shows 25 species and
appends the next 25 in place (about 1,800 px on a phone, from about 4,900).
Pages and charts
- Names on the co-occurrence chart no longer run into each other, and the life-list total
is no longer written across its own curve. The chord diagram wrote each name at the
middle of its arc, so species heard with few others had their names drawn across one
another; names are pushed apart along the ring only as far as it takes. The life-list chart
ended at its highest point, so its label was clamped below that point with the curve's
last stretch through it; the plot leaves headroom and the label sits above. A phone could
be pushed 84 px sideways by the Data tab, which printed the database's full path
unwrapped. The layout gate skipped every<svg>, so it could not see the first two; it now
grades chart labels as the turned rectangles they are. It does not see a line crossing a
label (the life-list defect was found by looking at the picture). - Smaller page defects. The Settings page printed
source\'s(a Rust escape inside a
raw string); the species page's Detections tile read2583under a2,583pill; a
detection's Details table labelled half its rows in another style; Diagnostics overflowed
a phone once models were configured (a finding quoting a model path cannot break); the
empty and failure fragments drew an<h3>under an<h1>(axeheading-order); Station
tabs had up to four<h1>s and restyled the whole page (five folded-in pages brought
stylesheets with rules for bareinput,label,table,h1andcode, which displaced
the header's search box); a stray<clip>showed the Raven export path as
recordings//raven.txtand a report closed one<div>too many; the count over the
tallest bar of both bar charts was drawn outside the SVG; the Today "Getting ready" links
were told apart by colour alone and the species list told a station that had heard nothing
"No species match this filter yet." (found by sweeping a station with no detections,
QA_EMPTY=1); a focused control kept its shape and a 2 px ring 3:1 against its
background (:focus-visiblehad squared off 948 of 1,156 focus stops); digits are
tabular everywhere (280 of 879 figures were proportional); the sign-in fields have a 3:1
edge; placeholders have the shape of what replaces them and the weekly-report column no
longer widens in steps; the onboarding wizard opens with the card that matches the stored
threshold, whether written0.7,0.70or.7. - The manual's images and directions follow the interface. Every image in
docs/book/images/(24 desktop, 24 phone, the share page and the wizard's Accuracy step)
was regenerated from a fixture built from this tree and looked at; they had been taken on
v0.8.0, anddetection-detail.pngwas the "Detection not found" page. Rendering them
showed the text sending readers to places that were never there: Station → Capture
where the navigation says Settings, Settings → Accounts (the tab is Access),
Settings → Location has a detect button (that is the wizard's), Admin → Settings →
MQTT (MQTT has no settings page), Normalise Clip Loudness (the control is "Even out clip
volume"). The manual, wizard, dashboard checklist, doctor, station-health alert and the
installer's config comment are corrected, and the heading on every Settings tab reads
"Settings — Capture".the_text_names_the_screens_the_ui_haschecks every Settings
breadcrumb in the manual, README, Rust sources and installer against the tabs the page
renders; it failed on 6 of 6 headings and 42 breadcrumbs before. It does not check the
step after the tab, or prose that names a screen without an arrow. The Today Page Layout
editor is only on the full/admin/settingspage; the manual now says where. - The published manual. "Suggest an edit" was a 404 on every page (mdBook 0.5 expands
{path}from the book root;scripts/check-book-links.pynow fails on an edit link that
names no file: 46 findings before, 0 after). Links in running text were told apart by
colour alone, four tables had an empty first header cell, and scrolling tables and code
samples could not be reached from the keyboard; axe found all three and the fixed pages
report none. The accessibility stylesheet reaches every released manual
(docs/book-theme/a11y.css, copied into each tree bybuild-versioned-docs.sh), and
the docs site gate loads the built site in Chromium: the version switcher on the 404
page, a phone's menu bar keeping its title, and axe at WCAG A/AA on six pages in two
themes at two widths. Three book defects the release rehearsal found:parity.mdsaid
CONFIDENCE0.70 was this project's "since v0.17.1" (it was 0.75 up to and including
v0.17.1),migration.mdswallowed " on " as an HTML tag, andsettings.md
jumped from h1 to h3. - Documentation that was not true. The FAQ and installer said the default model is "the
same neural network BirdNET-Pi uses" (it was its successor); three pages described a
rain-and-wind "quality pre-filter" that does not exist; the tuning guide gave a default of
0.7 (it was 0.75, and is 0.70 now);SF_THRESH=0was labelled "disabled" while a species the
occurrence model has no entry for is never let through; the storedweekwas called the
ISO week (it is the 48-week value); the overlap docs said 3-second windows (V3.0's is
4.5 s); the audio docs described an "Add-RTSP wizard" that does not exist; the guide and the
OpenAPI description counted the token-gated API wrongly (ten bearer writes and eight reads
are eighteen;the_prose_counts_what_the_route_tables_holdkeeps them honest); the
architecture documents called the resampler polynomial (it is windowed-sinc);--labels-dir
was documented withlabels_de.txt(the loader readsde_labels.txt); a species card
told you to pass--image-cache-dirwith the cache configured.--doctorsaid a missing
model "will use the bundled default at startup": no default is bundled, and with no
model the web UI runs and the detection daemon does not start. It is a warning naming both
ways to install one; the analytics line says an embedded extension is in release binaries
and that a build from source orcargo installfetches it on first use. - OpenAPI. Ten served paths were missing (the API root,
analytics/abundance,
analytics/phenology,detections/export/raven, the per-clip Raven and Audacity tables,
soundlevel,species/tracking, both WebSockets), andGET /api/v2/display(the clock
and temperature defaults) had no entry. A gate reads the router and fails by name on a
route with no entry or an entry no route serves. Redocly itself was not run.
Continuous integration and release
- The gates and workflows had defects of their own. The layout gate reported text that
is cut off and not drawn (the Today feed's "Ruby-throated Hummingbird" on a phone), which
failed its first CI run; it clips each text rect to every ancestor that clips its overflow,
andlayout-selftest.mjsholds both directions. Six# observedjob durations were stale
(scripts/check-ci-config.pycompares them with recent runs).coverage.yml's Codecov
step testedenv.CODECOV_TOKENbefore its ownenv:existed, so the upload could never
run; the sticky pull-request comments incoverage.ymlandmutation.ymlran on forks,
whose token is read-only, and now skip them; the release notes' download table omitted
the macOS archive. The MSRV job and the Docker builder were not running the MSRV (a
rust-toolchain.tomlnamingstableoutranksrustup default, so "MSRV (Rust 1.95)" ran
the current stable); the job exportsRUSTUP_TOOLCHAINfromrust-version, asserts
rustc --version, checks--all-targets --locked, and.dockerignoreleaves the file out
(cargo check --workspace --all-features --all-targets --lockedpasses on 1.95.0 with no
warnings). The mutation and supply-chain workflows' scheduled runs were cancelled by every
merge (they shared a concurrency group); they have their own. The browser-QA lockfile was
ignored by two.gitignorefiles whileosv.ymland thea11yjob named it; it is
tracked.typosread the DuckDB Foundation's Dutch legal name as a misspelling. - Release rehearsal fixes. The Homebrew formula template named files and steps that do
not exist (a.tar.gz.sha256sidecar the release deletes,docs/MACOS.md, a
birdnet.conf.examplenothing provides) and did not installNOTICEor
THIRD_PARTY_LICENSES.txt; it is still a template with a zero sha256. The issue chooser's
two contact links led nowhere (Discussions are off; private vulnerability reporting is
off), as didSUPPORT.mdandCODE_OF_CONDUCT.md's enforcement contact; the PR template's
gates omitted--all-features;CONTRIBUTING.mdlacked a fresh machine's prerequisites;
docs.ymlgained the tag gatedocker.ymlhas (a stable tag offmain, or one whose
Cargo.tomlnames another version, moved the site root to that manual whilerelease.yml
rejected it);RELEASING.mdandCITATION.cff(which named the V3.0 network) are
corrected. - Smaller: test passwords are built at run time (62 literals CodeQL would follow into a
passwordparameter) andtests/no_literal_reaches_a_credential_parameter.rsrefuses a
new one; the sixTODO(O-nn-followup)comments are resolved or recorded in
docs/TECH_DEBT.md, and a test refuses a new one; 24#[allow]attributes that silenced
nothing are deleted and 19 more trimmed to the lints they still silence. - The Apple Silicon macOS binary links again. The ONNX Runtime update (
ort2.0.0-rc.15)
brought CoreML code that calls Clang's___isPlatformVersionAtLeast, and the link of
aarch64-apple-darwinfailed with "Undefined symbols", so a first attempt at this release
built no macOS binary.build.rsnow adds Clang's ownlibclang_rt.osx.ato the binary's
link line on macOS, which also covers a source build orcargo installon a Mac. The Linux
binaries are unaffected. A macOS runner built the tree with it and the binary started; no Mac
has run it for anything beyond--versionand--help.
Added
Setting up and moving in
- A new station can be set up from the browser alone. The setup wizard has a Model
step (install the 54 MB classifier with a progress bar, SHA-256 and free space
checked), a microphone picker listing the sound cards with a two-second level test (or a
stream address, or a device name), plain-language accuracy and alert presets, and a last
page that restarts the station (systemd restarts it; a binary started by hand shuts down
cleanly and re-executes itself). Each Settings field says whether it applies immediately
or after a restart (one table,settings/applies.rs, each entry naming the consumer that
was read), and a banner lists changes still waiting. 34 more settings have a form (MQTT
trust files, alert limits, species filters, backups, storage, BirdWeather threshold and
blur, embed origins;EXTRA_SETTINGS), anddocs/book/reference/where-to-change-it.md
lists every known config key, setting and flag with its page or the reason it is
file-only; a test fails for one with neither. Fifteen numeric settings are range-checked
at the form, the API and--doctor(validate::FIELD_BOUNDS; warnings in the file, since
an error would revert the whole station to its last-good file);RECORDING_LENGTHis read
asSEGMENT_DURATIONwhen that is absent. Not run: the microphone list and level test on
real hardware, and a restart under systemd. - Moving from BirdNET-Pi is a browser flow, and a settings file is read key by key.
Station → Data → Move from BirdNET-Pitakes yourbirdnet.conf(andapprise.txt,
body.txtand the three species lists) and shows every line as a table (imported,
converted, needs a look, set inbirdnet.confonly, not carried, left as it is, or
refused) before anything changes; Apply writes through the same validation as the
settings page, and one audit row names only the keys. The mapping lives in one table
(birdnet_migrate::conf::RULES) that the importer,--doctor("BirdNET-Pi's X is our
Y", or plainly "no equivalent") and the generated table in
docs/book/guides/migration.mdall read. Secrets (BirdWeather ID, Apprise URLs,
passwords, RTSP and Flickr credentials, the heartbeat URL) are shown only as "(hidden, N
characters)" and never logged; BirdNET-Pi's habit of notifying on every stored detection
is written down as an explicitnotify_confidence = 0rather than silently becoming
this station's 0.80; the species lists are added to, never replacing, the station's own;
apprise.txtis copied into the data folder, which the full backup does not include.
CLI:--import-birdnet-pi-conf PATH(a dry run unless--apply-import).
a_birdnet_pi_conf_is_planned_key_by_keyruns BirdNET-Pi's own 60-key default file
through it. Before this, 40 of those 60 keys were reported by--doctoras "not a
setting this station reads". - Your BirdNET-Pi recordings come with the detections. The same page imports the clip
tree (By_DateorExtracted/By_Date) in copy, hard-link, move or leave modes, with a
dry run, a disk-space check (a copy that would push the disk past the purge threshold
is refused), a path check that never follows a name out of the tree, a progress bar and
a resumable second run; imported detections play from/api/v2/recordings/.... CLI:
--import-birdnet-pi-clips PATH --clips-mode MODE. Imported rows keep BirdNET-Pi's ISO
Weekand are marked byimport_batch_id; this station stores its own 48-week-year
week, so grouping imported and new rows by week mixes two calendars. Written against a
synthetic tree in BirdNET-Pi's real layout, not a real installation. - Notification triggers are independent.
APPRISE_TRIGGER/notify_triggeraccepts a
list (each,new-species,new-species-daily) andoff; BirdNET-Pi's three flags (every
detection, first of a species ever, first of a species each day) can be set in any
combination. Not the same as BirdNET-Pi: it sends one message per matching rule; this
station sends one message with every reason joined. Tested against a transcription of
BirdNET-Pi'snotifications.pyover all eight flag combinations. The settings page offers
the five common choices and keeps a list it cannot name as "Several rules". FULL_DISK=keepis honoured. The settingfull_disk_action(purgeby default, as
before;keepfor BirdNET-Pi's "keep everything, stop recording") makes the recordings
disk manager stop deleting and raise a recording hold; capture stops, resumes once usage
is 2 points under the threshold, and the hold is thebirdnet_recording_heldmetric and
a Station health condition. Only the recordings directory takeskeep; the stream
directory still purges. A migrated "keep" station never has a clip deleted.- BirdNET V2.4 is installable, and measured against BirdNET-Pi.
--install-model birdnet-v24fetches birdnet-go's published FP32 ONNX of the network BirdNET-Pi and
birdnet-go run, with its labels;birdnet-v24-range-v2fetches the matching occurrence
filter. Both URLs are pinned to a repository revision and a sha256.tools/parityruns
BirdNET-Pi's own unmodified code on its FP16 TFLite model and writes goldens;
tests/v24_matches_birdnet_pi.rs,tests/v24_range_filter_matches_birdnet_pi.rs,
tests/parity_dump.rsandtests/resampler_matches_resampy_parity.rsdiff this project
against them, and CI fetches the models, checks their sha256 and runs them. What was
measured, what differs and why is indocs/book/guides/parity.md(120 recordings:
native-48 kHz audio decodes bit-identically; the same set of labels at or above 0.7 in 899
of 900 windows; the range-filter model returns BirdNET-Pi's exact species list for all 265
(place, week) questions).tools/model-convertconverts BirdNET-Pi's two range-filter
models to ONNX (max difference 3.7e-6 from TFLite over 1,344 calls; v1, BirdNET-Pi's
default, is not hosted anywhere, so it is not in the catalogue).GET /api/v2/models/cataloggainsuse. - The species whitelist, with BirdNET-Pi's meaning. BirdNET-Pi's
whitelist_species_list.txtlets a species through the occurrence filter when the range
model holds it back, and does nothing else (itsrun_analysischecks the include list,
then the exclude list, then occurrence-or-whitelist). The core filter had awhitelist
field nothing set, which added the whitelist back after both lists, overriding them. It
is settable under Station → Capture → Species ("Always allowed") and in the Settings form
(species_whitelist), re-read by the daemon within about half a minute, and applied in
BirdNET-Pi's order. It matters only on a station with a location and an occurrence model.
SpeciesFilter::set_liststakes aSpeciesLists. - "Why isn't a bird heard here?"
GET /api/v2/species/occurrenceand the page
/species/occurrenceshow what the range filter says about a species for the station's
place and a week: the model's probability, the threshold in force and whether the station
admits it, with the reason (the range model, or your lists). A species page for a
held-back bird links to it. The answer is a 503 with areasonwhen the station has no
location, range model or a model that failed, never an empty list; the position is shown
rounded to 0.1 degrees. The first ask loads a second copy of the range model (14.7 or
29.5 MB on disk; its memory was not measured); an answer is kept for 60 s. - Bird names in 35 languages besides English. Choose one under Settings → System &
Display → Common-name language, or setDATABASE_LANG(--lang,BIRDNET_LANG); it
applies when the station next starts. The names are BirdNET-Pi'smodel/l18ntables at a
pinned commit, reduced to the genuine translations and compiled into the binary: 7,008
species across 35 languages (crates/birdnet-core/data/l18n/, with each source file's
sha256 andscripts/build-species-names.py). Only what is drawn changes: a detection is
still stored under the classifier's English name, so switching language splits nothing,
species lists keep working, and links, forms, the JSON API, exports, MQTT and webhooks
carry the stored name. Pages, feeds, notifications, the weekly report and the live feed
draw the translated name, and the search boxes and command palette understand a name
typed in that language. A language does not name every species: the Settings picker,
--doctorandGET /api/v2/languagessay how many of the installed classifier's species
each names, and a species without a name keeps the classifier's. Before, the setting was a
dead control. Held byevery_name_a_reader_sees_is_in_the_stations_language(31 pages
and feeds fetched from a German station show German names and none of the English ones).
Limits: the sort order is the stored name's; an alert rule's webhook body carries the
stored name; non-Latin scripts need a system font a bare Pi kiosk may lack; the names are
upstream's and unreviewed; and whether names derived from Wikipedia may be combined with
CC BY-NC-SA 4.0 is open (docs/HANDOFF.md).docs/book/admin/species-names.md.
Listening, display and parity with birdnet-go
- Listening and display options. Every player can boost a quiet clip (to +24 dB), cut
low rumble and run a compressor in the browser, ending in a soft limiter
(static/listen-fx.js; the settings live in the browser, and the stored recording and
the download are never changed);tools/visual-qa/listen-fx.mjsrenders real clips
through the page's own graph and fails if the output exceeds the limiter's ceiling (its
counterpart without the limiter must go over full scale). Spectrograms take a colour map
(viridis, still the default, magma, inferno, grey, inverted grey) and a dynamic range
(60, 80, 100 or 120 dB, default 80) that show on the next image: the answer is revalidated
instead of kept an hour. The Today page can show one picture with a title
(dashboard_image: BirdNET-Pi'sCUSTOM_IMAGE, a webcam frame written to a file; images
only, by their first bytes, at most 8 MB). The clock (12 or 24 hour) and temperature (C or
F) have a station default and a per-browser choice through one formatter
(GET /api/v2/display), and a test fails on a hand-written time or temperature; chart axes
and tooltips keep 24-hour.IMAGE_PROVIDER=avicommonsadds AviCommons photographs (no
key; CC0, CC BY and CC BY-SA only). Opus and AAC clip export is declined: the decoder
cannot read them. The help text ofcustom_image_dirno longer claims to be
CUSTOM_IMAGE. - birdnet-go parity: P-2, P-9, P-10, P-11, P-12, P-14, P-15, P-21, P-22. A per-species
minimum interval between detections, 1 s to a day (species_intervals, migration 56;
Station → Capture → Species; a species without a row usesDUPLICATE_INTERVAL_SECS);
MQTT client-certificate TLS (MQTT_CLIENT_CERT/KEY, a pair or neither,--doctor
checks the pair);EMBED_ORIGINS, an origin list (never*) that may frame the station
(frame-ancestors 'self'plus the list); learned dynamic thresholds listed, counted and
reset (GET /api/v2/dynamic-thresholds,…/stats,…/{species}; a bearerPOST …/reset; an admin card); BirdWeather upload threshold (BIRDWEATHER_THRESHOLD) and
position blur (BIRDWEATHER_LOCATION_ACCURACY, metres up to 50,000, uniform over a disc,
a new offset per post: it hides one post, not a station that posts for a year);
hardware.json(CPU, memory, USB and sound devices, no serials) in the support bundle;
test buttons for MQTT, eBird and weather that show the real failure; a weather JSON API
(/api/v2/weather/latest,hourly/{date}[/{hour}],detection: open like the other
reads, null never 0, metric units; nosun,moonordaily, on purpose, since a public
sunrise time gives away the station's longitude); a Secure connection card with the local
CA download on the Access tab. Not matched: per-species actions, per-detection weather
(A-6), and the range explorer's what-if, CSV and rebuild. - A cooldown on webhook alert rules. A rule on
Strix*posted once per 3-second chunk
for as long as an owl called. Each rule has a per-species cooldown in seconds (migration
55,alert_rules.cooldown_secs; at most 7 days;0, which every existing rule receives,
behaves as before). The gate runs on the detection's own timestamp, applies to webhook
actions only, and returns its window when the request never left. The rule export is
version 3 and omits a zero cooldown: a version-2 file still imports, and an older station
refuses a version-3 file instead of dropping the limit. The form has "Cooldown (minutes, 0
= every match)", 5 by default; a blank, fractional, negative or over-7-day value is
refused, and the list says "cooldown 5 min" or "no cooldown". (The daemon had enforced it
before the form could set it, so UI-made rules posted once per chunk.) - Settings → Detection has a Privacy Rank field (default 10, 0 = off, up to 12000),
which could only be set inbirdnet.conf,BIRDNET_PRIVACY_RANKor--privacy-rank. The
page andPUT /api/v2/settingsrefuse what would be cut to 0 (the rule off, reported as
saved): a fraction such as the Privacy Threshold's0.02,10%,1,000, a sign, or more
than 12000. A flag or variable other than 10 still beats the page. - Station Health says when the data lives on the container's own layer. A data directory
that is not a mount keeps the database and recordings on the container's writable layer,
where recreating the container deletes them. The banner, the header badge and a new
--doctor"Data volume" check (a warning, never an error, so it cannot park the unit; it
names-v birdnet-data:/data) fire when the process is in a container and the data
directory's mount is the image-layer root; a volume, bind mount, tmpfs or a Pi with an
overlay root is not flagged. The shipped image declaresVOLUME /data, so a baredocker rungets an anonymous volume, which counts as a mount and is not flagged (docker rm -v
or--rmstill deletes it). Tested againstmountinfofixtures modelled on Docker's
output, not in a container. - A classifier that fails every window is reported. After ten failed analysis windows
in a row (an error, or scores that are not numbers) a classifier is failing until it
answers one: a station-health alert names it (classifier:<id>),/api/v2/healthlists
every classifier's state and makes a failing one a?strict=1fault, and Prometheus gets
birdnet_model_failing,birdnet_model_consecutive_failed_windows,
birdnet_model_windows_totalandbirdnet_model_last_success_timestamp_seconds
(birdnet-go's threshold and error classes). Before, it was one journal line per segment.
Streams, system and analytics
- Live audio as HLS, so a phone keeps playing through a network handover.
POST /api/v2/streams/hls/{source_id}/startanswers with a token and a playlist of two-second
AAC segments, withstop,heartbeatandstatusbeside it (birdnet-go's routes and
fields). Oneffmpegper source and pitch shift serves every listener, within the four
live-stream slots/streamuses; a stream ends with its last listener or a minute after
its last fetch. The Live view uses it where the browser plays HLS natively (Safari, iOS,
Android Chrome) and keeps MP3 elsewhere. A private station'slive_audiocarve-out opens
it. - Streams other than RTSP, and audio-only cameras. A network source can be an HTTP or
Icecast stream, an HLS playlist (.m3u8), an RTMP server, MPEG-TS over UDP, or SRT; the
URL decides, and each gets the ffmpeg options its protocol accepts (the RTSP options made
ffmpeg refuse an HLS playlist and made an RTMP reader listen instead of connect). Checked
against MediaMTX 1.21.2 and ffmpeg 6.1, recording and listening live, and through a
stalled server. Another scheme is refused on the form;rtp://is not supported; a UDP
stream is listed in the Live view as spectrogram only. An RTSP source can ask the camera
for its audio track alone (a source's edit form: Tracks requested): by default the
station sets up every track and discards the video, holding one of the camera's few video
sessions for as long as it listens. A camera with no audio track then fails visibly, as
a down source. - System and operations.
GET /api/v2/system/network(every interface with addresses,
hardware address and byte counters: the address to type whenbirdnet.localdoes not
resolve),GET /api/v2/system/processes(the station's own process and what it started,
with CPU and memory), andGET /api/v2/system/database/overview(engine settings, each
table's rows and size, the last integrity check and VACUUM, write latency, the last 24
hours' arrivals;nullwhere this station does not measure a figure), all behind the API
token.birdnet_audio_analysed_seconds_total{source}: seconds of audio analysed per
source; its rate is about 1 for a source that delivers continuously and falls below that
when audio goes missing inside segments, which a count of files cannot show. CPU profiles
for administrators in builds with theprofilingfeature (GET /admin/debug/pprof/profile?seconds=30, approfprofile orformat=folded; not in
release builds; one at a time). - birdnet-go's analytics and insights routes (their parameters, defaults and fields;
SQLite-backed ones answer with the analytics engine off):analytics/species/daily,
species/summary,species/detections/new,time/distribution/species,
time/succession,time/heatmap(15-, 30- or 60-minute slots, orformat=csv),
time/dawn-onset(the third detection of the day against civil dawn, on days with at
least five),time/year-over-year,confidence/distribution,sources;
insights/phantom-species,insights/expected-todayand…/regional(an error, not
every eBird species, when the station's own history cannot be read),insights/dawn-chorus,
insights/migration; anddashboard/kpis(the streak uncapped where upstream stops at
90 days). Past-day novelty is read from the history as of that day, and first and last
detection carry the UTC offset in force, ordered by instant, so the hour the clocks go
back is placed correctly. - What is heard just before a species, as well as after it, on the Analytics page's
follow-on card (sequence_next_noderun backward) and
GET /api/v2/analytics/previous-species?before=…. The behavioural functions are
checked, not just loaded:--verify-extensionand/api/v2/analytics/status
(self_test) run every function the station uses on fixed events and compare each answer
with v0.10.0's. - MQTT says when a species was last heard (
species_first_detected_atand
species_last_detected_at: the earliest and latest detections before this one;null
for a first-ever detection, left out when the history could not be read, so a "new
species" automation cannot fire on a database error)./api/v2/detections?source=
filters by audio source. Pirate Weather is a fourth weather provider
(WEATHER_PROVIDER=pirate-weatherwithWEATHER_API_KEY), written against its published
field definitions and not checked against a live response. - Arrange the Today page. Settings → Today Page Layout shows, hides and reorders the
page's parts with Move up, Move down and Show/Hide buttons (no dragging, so a keyboard or
touch screen can do it); the page is rebuilt in that order. A new station shows
everything until its first detection.
Project documents
- The published manual is versioned. The site root is the latest release's manual, each
release from v0.17.1 on stays at/vX.Y.Z/rebuilt from its own tag, andmainis at
/dev/; a version menu sits in the toolbar, with a banner on any page that is not the
latest release (scripts/build-versioned-docs.sh). STABILITY.md(proposed), a documentation map, and the checksmainshould require.
STABILITY.mdsays what a release may and may not change across the station's data,
configuration, HTTP API, metrics, artefacts and platforms, and how a break is announced;
docs/README.mdsays which documents are current and which are records;
.github/workflows/README.mdlists each workflow and the seventeen checks that run on every
pull request (check 8 ofscripts/check-ci-config.pyfails when a name on that list stops
being a job or its workflow gains a path filter);docs/HANDOFF.mdrecords the repository
settings only the maintainer can change.
Installation
One-line installer (Raspberry Pi / bare-metal Linux)
curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/download/v0.18.0/install.sh | sudo bash -s -- --version 0.18.0Run it as root via the pipe above. Do not use
sudo bash <(curl ...)— sudo
closes the process-substitution file descriptor, so the script never loads.
Download a binary directly
| Target | Platform |
|---|---|
aarch64-unknown-linux-gnu |
Raspberry Pi 4 / 5, ARM64 Linux |
x86_64-unknown-linux-gnu |
Standard 64-bit Linux |
aarch64-apple-darwin |
Apple Silicon Mac (no sudo) |
Archives are gzipped tarballs containing the stripped binary,
README.md, CHANGELOG.md, LICENSE, LICENSE-UPSTREAM, NOTICE,
THIRD_PARTY_LICENSES.txt, install.sh, and a help/ directory with the rendered operator
manual (served offline at /help). Each binary ships the DuckDB
behavioral-analytics engine built in; install.sh enables it by
default (for a manual run, pass --analytics-db).
Runtime requirement: glibc 2.39 or newer — Raspberry Pi OS
Trixie, Debian 13, or Ubuntu 24.04. Pi OS Bookworm
(glibc 2.36) is not supported by the native binary: run the
Docker image instead (it bundles its own runtime) or upgrade to
Trixie. There is no 32-bit (armv7) binary — use the 64-bit Pi OS.
Model
The BirdNET+ V3.0 model (~541 MB) is not bundled in the archive.
On first run, install.sh and the Docker image fetch it
(sha256-verified) from the shared, arch-independent model release
https://github.com/tomtom215/BirdNet-Behavior/releases/tag/models-v3.0-preview3
— the same GitHub origin as the binary — falling back to Zenodo
(the upstream source) if that asset is unavailable. The install is
offline-capable after that one fetch.
Docker
docker pull ghcr.io/tomtom215/birdnet-behavior:0.18.0The image includes DuckDB analytics (no separate -analytics tag).
Multi-arch manifests cover linux/amd64 and linux/arm64.
Documentation
The operator manual for exactly this version, built from this tag:
https://tomtom215.github.io/BirdNet-Behavior/v0.18.0/
The binary serves the same manual offline at /help.
Verification
Checksums
sha256sum -c SHA256SUMS --ignore-missingSLSA build provenance
Every release archive carries a signed SLSA build provenance
attestation produced by this workflow run. Verify it with the GitHub CLI:
gh attestation verify \
--repo tomtom215/BirdNet-Behavior \
birdnet-behavior-0.18.0-aarch64-unknown-linux-gnu.tar.gzSoftware Bill of Materials (CycloneDX)
A CycloneDX 1.5 SBOM for the full workspace is attached as a
release asset in both JSON and XML formats:
birdnet-behavior-0.18.0-sbom.cdx.jsonbirdnet-behavior-0.18.0-sbom.cdx.xml
Consumers can ingest these into Dependency-Track, GitHub
Dependency Graph, or any CycloneDX-aware vulnerability
scanner. Both files are signed by the same SLSA build
provenance attestation as the binaries.