Skip to content

BirdNet-Behavior v0.19.0

Latest

Choose a tag to compare

@github-actions github-actions released this 01 Nov 14:33
· 17 commits to main since this release
ec6f71e

Added

  • Charts with a vocabulary of their own. The 24-hour charts sit on the day's real sky (night, a dawn
    wash, day, a dusk wash, placed by the station's own sunrise and sunset, and absent when the station has no
    location); columns are notched so a column can be counted (a seam at every 1, 2 or 5 times a power of ten,
    at most twelve of them, and the chart says what a notch is); heat grids are discs whose area and colour are
    the same number; a note in the display italic names the busiest bar, falling back to the bare number when
    there is no room; the sun and moon ride a rail under the hours. Today's hour columns are now stacked by who
    was heard (the day's eight most heard each in their own colour, everyone else as one band, the column still
    exactly the hour's count) and the strip is drawn twice, for a card and for a phone, where it used to be
    scaled to a third of its size. docs/design/charts.md ("The vocabulary beyond the bar") lists each mark and
    what a test holds it to. New tokens: --sky-night, --sky-dawn, --sky-day, --sky-dusk.

  • Three views, each the form its question needs. Every song (Today, beside "By the hour"; the choice is
    remembered on the device): every detection of the day as a glowing note, time across and the station's
    confidence up, the station's own cut-off drawn as a line, on the day's real sky; it is fed by a new JSON
    route, /pages/today-songs, which carries at most 2,000 songs and says how many the day holds when it is
    cut. Who led, and when it changed (Year in review): the year's eight most-heard birds as ranks over the
    months with a month scrubber, the true counts as bars from zero, a gap for a month with none and "not yet"
    for a month that has not come. When each bird joined your list (the life list): a timeline of firsts with
    each bird's portrait and a month-by-month list on a phone. The last two carry a table of the same numbers
    behind the picture (and the first of them says its finding in a sentence), so a browser with no script
    still has them; Every song is drawn by its
    script alone (skysong.js), and with none the page keeps "By the hour", which is the default view. The
    scripts are sealed modules, and /pages/today-songs answers a real 500 on a failed read, because its
    reader is fetch() and not htmx; the source scan that forbids a 5xx from a page handler names it as the
    one exemption, and a test holds the reason.

  • Who answers whom (Patterns, Behavior). After one bird is heard, which others are heard within a minute?
    A grid whose rows are the bird heard first and whose columns are the birds heard after it, from the
    extension's sequence_count with a time constraint: not symmetric, which is the point. A pair seen fewer than
    three times is a small ring and never a finding; a best pair at the floor is "nothing stands out yet", not a
    headline. New in birdnet-behavioral: AnalyticsDb::answers, queries::answers_sql, types::Answers; the
    live test seeds a day in which each rule (a reply in time, a reply too late, the same instant, a bird
    answering itself, direction) has its own count, and was watched failing against a pattern without (?t>0).

  • The chorus assembling (Patterns, Sequences). Once the dawn starts, how long before it is a chorus? A field
    of discs: a column is a time allowed (1 minute to 5 hours), a row is how many of the station's usual dawn
    voices have joined in their usual order, and a disc's area is the share of mornings with at least that many
    together within that time; a bold line is the typical morning and a pale band the middle half. It is drawn from
    window_funnel asked at a ladder of 14 windows, not from window_funnel_events, which the plan named: a probe
    against behavioral 0.10.1 showed that function reports the latest occurrence of each voice before the next,
    so the gap between two of its steps depends on a tie-break and not on the birds (recorded in
    docs/design/insights-and-organisation.md, section 6). Mode strict_increase, so voices heard in one audio
    chunk do not join in either order. A sentence leads the card and is silent below three voices or seven
    mornings; a phone gets a drawing of its own. New: AnalyticsDb::assembly, queries::assembly_sql,
    types::{Assembly, AssemblyParams, ASSEMBLY_WINDOWS}, GET /api/v2/analytics/assembly, and, for the grid
    above, GET /api/v2/analytics/answers. The live test seeds eight mornings whose depths at every window are
    worked out by hand (including one morning of voices that share an instant); the figure's mutants and the
    handler's, run against a real engine, are listed in the handoff.

  • The Patterns bar says what kind of question each tab answers. The seven tabs are grouped under When
    (When active, Dawn chorus, Trends), Who (Who sings together, Migration) and How (Visits, Sequences).
    No address changes: every ?tab= key is the one it had, and behavior keeps meaning the bursts-and-returns
    page; the running order, the chorus and who-answers-whom moved to the new ?tab=sequences. On a phone the
    strip scrolls to the tab you are on. The return-rate ledger and the running order are full width: each shared
    a row with the other on the one Behavior tab, and alone in half of a tab of its own they left the other half
    empty and squeezed three of the ledger's "100%" cells onto two lines at 1100 to 1440 px (33 at 1000).
    relational.mjs now fails on a rate broken over two lines, at 1440 and at 1000 px (red on the half-width
    card, green on the full-width one).

  • Every card on Visits and Sequences has the same four layers: one sentence of what the figure found (a
    function of the numbers that says its basis and is silent when there is too little to say), the picture, the
    table behind a disclosure, and a Method and export panel naming the extension function and its version,
    what was asked, what a number counts, the REST route that returns the same numbers where one exists, and a
    button that saves the table as a CSV. The other Patterns tabs are regrouped but do not have the four layers
    yet. New: frame::Method, static/figure-export.js, static/tab-strip.js, pages/behavioral_glance.rs.

  • scripts/preflight.sh: every check CI runs on a pull request that a developer's machine can run, in one
    command.
    Format; clippy on the whole workspace at each of the three feature sets CI builds; rustdoc with
    -D warnings; spelling (typos at the version CI pins); unused dependencies; cargo deny; cargo audit;
    OSV (scripts/osv-check.py, which asks the database CI's scanner reads about every locked package and applies
    the waivers in osv-scanner.toml, expiry included); the generated installer and CLI help; the manual's links;
    the licence notices; doc tests; with --msrv the check on the declared minimum Rust, and with --tests the
    workspace tests. A tool it needs and cannot find fails the run with the install line, and the closing list
    names what only CI can run (CodeQL, the container image, the install and upgrade smoke tests, the aarch64
    cross-check). Added because three kinds of failure had each cost a CI cycle on recent pull requests, all of
    them a check the workflows run and the author had not: the spell check, the manual's link check, and rustdoc.
    Its first run found a rustdoc error (a public doc comment linking a private function) and five spell-check
    hits in earlier commits of this branch; the root crate's tests then found that the new
    /analytics/assembly route answered 400 to a blank hour_start, which a page's control can send (it reads
    as "not given" now: a_blank_hour_is_not_given_rather_than_refused, red against the old handler).

  • A plan for organisation and insight (docs/design/insights-and-organisation.md): which of the extension's
    nine functions the product uses (measured, not assumed: two are drawn by no page and the modes, bases and
    pattern operators are mostly unused), options for regrouping the pages, and a ladder of insights from the
    casual birder to the researcher. New in birdnet-db: monthly_species_counts.

  • The kiosk is a plate. /kiosk draws today's birds as a page from a field guide: one figure
    per species, larger the more it was heard (area follows the square root of the count, with a
    floor so a bird heard once is still drawn), the Latin name beneath, a numbered key at the foot
    and the sheet numbered by the day of the year. It is redrawn only when the birds change (the
    page polls with a digest and the server answers 204), shows yesterday's birds in the small
    hours and says so, and has a day and a night palette chosen from the station's sunrise and
    sunset (?night=1|0, ?paper=0 for a small board). Each photograph is engraved in the browser
    and shown whole; a picture in CUSTOM_IMAGE_DIR is drawn as it was made, and a CREDITS.txt
    beside it is printed in the foot. The old display is ?mode=numbers; ?mode=latest shows the
    bird heard last. The idea is fugleramme's (https://github.com/arnegiacomo/fugleramme, Arne
    Giacomo Munthe-Kaas); no code or artwork is shared. New in birdnet-db:
    species_heard_on, from detections_analytic, so a rejected bird is not on the wall.
    The sheet is drawn on 1600 units and its smallest caption is 11.5, so the plate reads at the 8.5 px
    the browser gates ask of a chart label only from about 1180 px of window up; at 390 px they were 2.8 px
    (legibility.mjs, which found it). A window narrower than that now opens /kiosk on ?mode=numbers
    (the old display, readable at any width) unless the address names a mode, and kioskOpensReadable
    in interactions.mjs holds the rule: the phone case failed before, the two cases that keep the plate
    passed. The plate on a small screen is still small if it is asked for.

  • Every picture is shown with its credit. A photograph fetched from Wikipedia and Wikimedia Commons,
    AviCommons or Flickr is kept with its title, author, source and licence (a {key}.credit.json beside the
    file) and shown under the picture on the species page, in /api/v2/species/image/{scientific_name} as
    credit, and on a new page, /system/credits, which every page's footer links to and which lists the
    credited pictures the station holds for the birds it has heard. Until now the credit existed, for two of
    the three sources, only in the description of a JSON answer no page read, and the licences those
    pictures are under (CC BY, CC BY-SA) require it to be shown. A picture whose credit cannot be read, or
    whose licence is not a free one, is not used; a picture cached before credits were kept is replaced by a
    credited one the first time it is asked for (it is not given a guessed credit), and the credits page says
    how many are waiting. A link in a credit is followed only if it is an http or https address.
    The picture cache now refuses a key that is not a single file name or that holds .. (../victim,
    a/b, a..b, ., the empty name): the key is derived from the name in a request path, and with the
    credit file added beside each picture, CodeQL's rust/path-injection flagged the removal of it. Before,
    remove("../victim") on a cache beside victim.jpg deleted that file; three tests fail on the old code,
    and a_key_with_punctuation_in_it_is_still_a_file_name holds that species names with dots, brackets and
    × still cache. The alert stayed open through three forms of the check (a comparison with Path::file_name
    inside a helper, a contains("..") written in place, and deleting a file found by listing the cache
    directory). The last alert's argument is the configured cache directory, which CodeQL takes as a remote
    source through axum's State (tools/code-scanning/dismiss.sh records the same for the cache's earlier
    alert), so no check on the key can clear it. The credit lines on the species page are underlined like the other prose links (axe's
    link-in-text-block, in the light theme).
    On a phone each row stacks, its cells printing their column's name. The browser gates grade the page
    (/system/credits is in their route list; the screenshot fixture seeds credited pictures, an uncredited one and a
    plate), and book.mjs takes ONLY=<image name> to refresh one picture of the manual.

  • Display → Charts → Plate. A per-browser choice (bnb-illustration, data-illustration="plate") that
    draws each figure as a ruled sheet of paper with an italic title and draws each categorical series (the
    species in the streamgraph, the dawn and dusk bars, the migration ridges) in its own colour with straight
    strokes cut across it, in the manner of a field guide. The first version hatched everything and was
    worse to read: measured with the lines averaged into the area, adjacent steps of the heat-grid legend
    differed by 5.3 to 5.7 L* against 6.9 to 9.2 flat. It now keeps colour as the first cue and the stroke as a
    second, and draws sequential scales (the heat grid, co-occurrence ribbons, the terrain) flat. The stroke
    is the darker of the page's two colours in each theme, 3.19:1 or better against every series colour in
    light and 4.49:1 in dark, which a_plate_stroke_can_be_seen_on_every_colour_it_crosses holds (WCAG
    1.4.11 asks 3:1). the_plate_chart_style_keeps_its_promises.rs holds that no sequential class is
    hatched and that the stylesheet and the pattern file name the same patterns. tools/visual-qa/axe.mjs
    and layout.mjs take ILLUSTRATION=plate; both are clean with it on (axe: 168 route, theme and
    viewport states; layout: 336).

  • The soundscape terrain says what it found and marks the sun. Above the terrain, one sentence compares
    the share of the window's detections heard within two hours either side of sunrise with an even spread
    (four hours of twenty-four) and says which of three things is so: clearly more (at least 1.25 times the
    even share), clearly less (at most 0.8 times), or about the same. It is left out for a window under
    seven days or under 30 detections. A dashed line on each day marks sunrise and sunset on the station's
    local clock, with the offset in force on that day, so a change of clocks bends the line where it should.
    The text is in the JSON as take.

  • The kiosk draws public-domain plates where there are any. /api/v2/species/plate/{scientific_name}/file
    serves the operator's own art (X-Bnb-Image-Source: custom), else the nineteenth-century plate a built-in index
    names for the species (plate), else 204 (nothing to show; a 404 logged a failed request in the browser for
    every species without a plate, which the strict visual sweep caught on the kiosk), and the kiosk then asks for the photograph as before, so a bird with
    a plate never downloads a photograph it will not draw. The index (crates/birdnet-integrations/data/plates/index.json,
    built by tools/plate-index/, report in tools/plate-index/report.md) names a file on Wikimedia Commons from
    Gould's bird books, Naumann's Naturgeschichte der Vögel Mitteleuropas or the von Wrights' Svenska fåglar, only
    when Commons' own metadata calls it public domain, CC0 or Public Domain Mark and it shows one species; scans their
    uploader re-licensed (CC BY-SA) are left out. It covers 458 of BirdNET V2.4's 6,515 species (7.0%), mostly European and Asian
    ones: 17 of a fixed sample of 40 common European and North American species, and 1 of the 15 species in the North
    American screenshot fixture. A plate is fetched from Commons the first time the bird is heard, into plates/
    inside the picture cache, under the same offline switch as the photographs. In the browser its page is cut away
    (pageBounds), then cut again to its ink, so the birds fill the figure; its paper is levelled and, by day,
    multiplied into the sheet, so only the plate's ink and colour remain, and by night it is a lit print on the dark
    ground. A plate that is a picture to its edge is drawn as it is. The foot is rewritten from what was drawn: the
    books, whether a photograph was engraved, and the address of /system/credits, which lists the books and the
    plates held. A plate is credited to its work's author, not the file's free-text one, which on Commons is what the
    uploader typed (for Naumann it holds a digitiser's name and an email address).

  • Patterns pages share one frame. pages/frame.rs gives every tab the same head (the help
    link in the same place) and every chart a numbered figure with a how-to-read caption;
    pages/insight.rs writes the one-sentence finding above a chart from numbers the chart already
    holds, and says nothing on too little data.

Changed

  • The browser gates' fixture serves its own pictures. screenshot_server, which the accessibility, layout,
    legibility and visual sweeps run against, used to ask Wikipedia for each species' picture, as the shipped binary
    does. A run behind a proxy or a rate limit then drew initials where a run in CI drew photographs, so the strict
    sweep failed on one and passed on the other with the same code (the life list's six newest birds all 404ed),
    and an edit to an article could have moved CI's layout. Every species the fixture knows now has a credited
    picture and a line of prose from the fixture's own source (FixturePictures, served by the fixture at
    /__fixture/picture.jpg through the provider, download, disk and credit path the binary uses); a species it does
    not know has none, and the one picture seeded without a credit keeps its legacy state. With outbound HTTPS
    pointed at a dead proxy all 15 of the fixture's species answer 200, and so do they under a base path.

  • axe.mjs fails on any finding, not only serious and critical ones (AXE_FAIL_ON now defaults to
    minor,moderate,serious,critical). The species page had carried 12 moderate findings, one rule, through every
    sweep because nothing read them; a station's own gate should not have a tier that is printed and not read.

  • osv-scanner.toml carries no waiver. The one it held, GHSA-qwgh-2vcv-g2f7 for block_buffer 0.10.4 (waived
    until 2026-12-01), matched nothing: the advisory's record, last modified 2026-10-30, lists block_buffer 0.11.0
    up to 0.12.1 as affected and the lockfile holds 0.10.4 and 0.12.1. scripts/osv-check.py reports no advisory for
    any of the 649 locked packages.

  • The release workflow builds macOS on macos-15, not macos-14. GitHub retires the macOS 14 runner image
    on 2026-11-02 and failed every job on it in scheduled brownouts before that, which is how the release dry run
    for this version lost its aarch64-apple-darwin leg (and, with it, the package and Upgrade from v… jobs,
    which wait for the whole build matrix). Two steps were added to that leg: one refuses any runner whose
    uname -m is not arm64, so a label that resolved to an Intel image cannot cross-compile unnoticed, and one
    prints the binary's minos (the oldest macOS that will load it) into the run's summary. No deployment target
    is pinned anywhere, so that number is whatever the runner image's toolchain defaults to; nothing promises a
    floor while macOS is a preview.

  • Chart axes are a little darker in the light theme (--chart-axis is now oklch(61% 0.010 70)), because
    an axis label drawn on the new sky is graded against the sky and the old value read 2.75:1 there
    (chart-ramps.mjs grades it; it reports 146 checks, 0 failures). The sun on the daylight rail is
    --warning and not --dawn, which is 2.85:1 on the page.

  • Flickr's CC BY-ND is no longer fetched (ALLOWED_LICENSES is now 4,5,7,8,9,10, without 6). The kiosk
    engraves a photograph, which is an adaptation, and an ND licence does not allow one. A Flickr photo whose
    licence id is not in that list, or that has no page to credit, is refused.

  • Recordings and the three Reports tabs open with the shared page head (frame::page_head, one <h1>,
    the help link in the eyebrow; the print button is in the head on Weekly and Year in Review), and the
    CSS that styled the old openings, and other CSS no markup used any more, is gone. Today keeps its own hero, whose headline is written from the day's data and set at the larger hero size,
    and is held to the two rules it can share by today_has_one_h1_and_the_help_link_in_its_eyebrow.

  • Fourteen hand-written figure cards (on the Patterns tabs and the analytics dashboards) carry the fig
    class
    their "Fig. N" number implies; they were plain cards among sheets under the Plate style.
    every_card_that_numbers_itself_is_a_figure_sheet reads the marker and demands the class.

  • DuckDB 2.0.0 (duckdb 1.20000.0, from 1.5.6), with behavioral v0.10.1 and icu
    v2.0.0 embedded and pinned by sha256 (extension_pins.rs). Four things the move needed that a
    version bump does not show:

    • The crate's engine called itself v2.0.0-dev88614. libduckdb-sys 1.20000.0 compiles the
      2.0.0 release commit (d54cbbe3df, the git_revision of the official wheel) with DuckDB's
      fallback version macros. DuckDB names the extension directory after the version string only
      when it is a release, and after the commit hash otherwise; no registry serves a build under
      the hash, and the published icu was refused ("built specifically for DuckDB version
      'v2.0.0'"), which would have emptied every date-ranged dashboard. .cargo/config.toml now
      sets DUCKDB_VERSION and DUCKDB_PATCH_VERSION through CXXFLAGS, and
      the_engine_names_itself_as_a_release fails without it. A build made outside this tree
      (cargo install) does not get the define; RELEASING.md says what that costs.
    • DuckDB 2.0.0's stable C API is still v1.5.6. The runtime check that decides whether an
      embedded C_STRUCT extension fits the engine read the engine's release version as its C API
      version, which would have reported behavioral (target v1.2.0) as unloadable on 2.0.0
      although DuckDB loads it. It now reads the C API from a table
      (stable_c_api_of); a_correctly_built_binary_reports_no_mismatch failed on the old rule.
    • GNU ld could not link the binary. libduckdb-sys 1.20000.0 links a small archive whole-archive,
      which asks for duckdb_register_static_extensions, and rustc puts it on the link line after the
      rlib that carries libduckdb, so GNU ld has passed the defining object by then
      ("undefined reference"). lld takes any order, and it is what rustc 1.99.1 links x86_64 Linux
      with here, which is why the debug builds linked. The Docker image build for aarch64 (Rust 1.95, /usr/bin/ld) failed this way; the same
      error comes from the x86_64 debug binary with -C link-arg=-fuse-ld=bfd. build.rs now passes
      -Wl,--undefined=duckdb_register_static_extensions on Linux with analytics, and that link
      succeeds. Only the root package's own targets get it: a test binary of another crate linked with
      GNU ld was not tried.
    • The licence set follows the engine: third_party/duckdb-2.0.0/ (no libpg_query, which 2.0.0
      no longer bundles; the ICU text is carried over from 1.5.6), and
      gen-third-party-licenses.py now reads the engine's major from the crate version instead of
      assuming 1.
  • rusqlite 0.41 and the SQLite it bundles, 3.54.1 (from 3.53.2). One test helper needed a change:
    Connection::trace_v2 takes &mut self and returns a Result now (the helper ignored what would have
    been a silent failure to install the trace the test reads). The workspace library tests and every
    birdnet-db integration test pass. Comments elsewhere that say "measured on SQLite 3.53.2" are dated
    measurements and were not re-taken.

  • A plain cargo update (20 packages, all inside their declared ranges): notify 8.2.1, realfft 3.5.1,
    uuid 1.29.0, smallvec 1.16.4, rustix 1.1.6, xattr 1.7.0, unicode-width 0.2.3, openssl-probe 0.2.2,
    ctutils 0.4.6 and the pest family 2.9.5 for the manual's renderer, plus backtrace, gimli,
    object and addr2line. Every new package is MIT, Apache-2.0 or Zlib, which deny.toml allows
    (read from the package metadata; cargo deny itself is not installed here). The workspace library tests
    pass with the same counts as before the update. Not checked: that realfft 3.5.1 leaves the mel
    spectrogram bit-identical (the parity harness needs the model files, which this session did not have).

  • OSV-Scanner waives GHSA-qwgh-2vcv-g2f7 until 2026-12-01 in a new osv-scanner.toml
    (block_buffer 0.10.4, reached through sha1 0.10.7 from axum and tungstenite; release builds
    abort on panic, which the advisory's precondition needs to be caught). The fixed 0.12.1 is
    already in the lock for the newer digest chain; the entry goes when axum and tungstenite
    move to it.

  • CI config holds a pull request to the budgets it edits. Its run-history checks (a job
    near its timeout, a stale # observed) read main's last five runs, so a stalled apt mirror
    on 2026-10-25 and 10-26 put nine mutation rows over the line on every pull request that ran the
    check afterwards, whether or not it touched a budget. On a pull_request event those two checks now
    apply to the job rows whose budget, and the timeout-minutes: lines whose budget or # observed,
    differ from the pull request's base (the first parent of the merge commit, so the job's checkout is
    two commits deep). Push, schedule and dispatch runs still check every row, so an outgrown budget
    goes red on main; a pull request whose base cannot be found is checked on every row too.
    scripts/check-ci-config.py records the incident (12) and tests the rule in both directions.

Fixed

  • /pages/history-calendar wrote its sel value into the month buttons unescaped. The handler took any ten
    characters as the selected day, and "><zq-p>12 is ten, so a link carrying such a string put up to ten
    characters of markup into the buttons' hx-get attributes. The buttons are drawn when the history spans two
    months, which is every station past its first month; the strict CSP stops a script, so this was markup
    injection and not script execution. The sel_param code is in the source of v0.18.0 and v0.18.1 (from
    git log; those tags were not rebuilt). Now only a YYYY-MM-DD is accepted at the handler, and the value is
    percent-encoded where it is written. It surfaced because the release gate failed: the sweep that grades every
    page for this (a_query_value_is_never_echoed_as_markup) seeded today and yesterday, which fall in two months
    on the 1st only, so on every other day of the month the buttons were never drawn and the sweep was green. On
    2026-11-01 it was red for main's own CI and for the v0.19.0 release run. The seed now spans two months
    whatever the day (the_seed_spans_two_months_whatever_day_the_sweep_runs), and the handler and the renderer have
    tests on fixed dates, each seen failing against the shipped code. v0.19.0 was first tagged one commit before
    this fix; its release run stopped at that gate and published no GitHub Release, and the tag was moved to the fix.
    Its container image was pushed from the first commit and is replaced by the one built from the moved tag.

  • The morning running order drew its lines through its own words. The newest morning's heavy line cut through
    the right-hand count of the lanes below it ("167 mornings · 82%"), and the time written beside a large dot sat
    where that line left the dot whenever the morning moved steeply or doubled back, which is the case the figure
    is for. The words were drawn last with a halo, which hides a thin line and not a heavy one. The lines are now
    drawn in pieces that leave out the band each lane's words are written in (a header strip: the slant on either
    side still says which way the morning went), and each time is put on a side of its dot that its own line, the
    lane's words, the edge of the picture and (where the lane has room) every other morning's dot leave free: the
    first render of the book's figure had "08:22" with a dot under its first digit. Tests read the markup back as
    geometry: no line meets a name, a count or a typical time, no time is struck, and no time is written over a dot,
    for a late shallow morning, a steep one, one that doubles back and one at the edge of the clock; red against the
    old drawing, and twelve single-site mutants of the new code are each caught.

  • The species page had a landmark axe objected to. An <aside> holding the best-recording card was reported
    by landmark-complementary-is-top-level on all three species-detail states in both themes and sizes (12
    findings); it is a <div> now, and a recording card is not content complementary to the page.

  • A webhook test failed about one run in a hundred. a_redirect_within_one_origin_still_carries_the_credential
    answers a 302 and closes the connection without saying so, so the client sometimes sent the redirected request
    down the closed connection and met "Connection reset by peer" (6 failures in 600 runs of the test alone; it
    failed main's CI once, after the merge of #274). The stub now sends Connection: close (0 in 1,500). The
    product code is unchanged: a closed connection reused is an ordinary HTTP/1.1 race, and the error is logged.

  • "Neighbouring species" said a share of sessions that it is not. The probability is the share of the
    sessions that had a bird on that side at all (a session the bird opens has no bird before it, one it closes
    none after): measured, a bird followed by Jay on 2 of its 7 sessions reports Jay at 100%, and the flow's
    tooltip ("Share of sessions it came just after"), the guide and the card's Method note all read as if it were
    2 of 7. They now say what it is a share of (the_reading_says_what_the_share_is_of, red against the old
    tooltip; the_neighbour_card_says_what_its_share_is_of, which runs a real engine).

  • The kiosk answered 400 to a blank ar. The page sends the screen's width over height from a hidden
    control; ?ar= with the value empty (a script that failed, an address typed by hand) was rejected with "cannot
    parse float from empty string", on a display that runs unattended. A blank ar now draws the default sheet; a
    value that is not a number is still refused. (a_blank_aspect_ratio_draws_the_default_sheet_but_a_garbled_one_is_refused,
    red against the old handler.)

  • The weekly report called an unfinished week "quieter". It held this week's detections so
    far against the whole of last week's, so until Sunday every station was told its week was
    quieter than the one before, and by how much. It now compares the days that are over with the
    same days of last week, says "so far", and makes no comparison on a Monday morning.
    the_weekly_report_compares_like_with_like fails on the old page.

  • The When tab had two range controls that did not agree, and Trends drew three charts that
    When already draws.
    One control sets all four charts; Trends links to where the others live.

  • NOTICE and docs/book/about.md said photographer attribution is "shown on each image". No
    page prints it; the credit for AviCommons and Flickr pictures is only in the image API's
    description. Both now say so. The gap itself is not closed.

  • A stalled apt mirror no longer spends a mutation shard's budget. In main's five latest
    Mutation testing runs, 19 of 172 shard jobs spent 5 minutes or more in Install ffmpeg
    (16 of them 15 or more, up to 35.7), all on 2026-10-25 and 10-26, against a median of 0.3;
    the log of one shows azure.archive.ubuntu.com going quiet for 2.5 to 4 minutes mid-download.
    Nine rows were then reported by the CI config job at 78% to 101% of their budget, six of them
    cancelled at it, and each had such an install in the run it was measured on. The step now
    gives up on a stalled connection after 20 s (three retries) and is limited to 15 minutes, so
    a bad mirror fails that step by name. The check reads main's last five runs, so those nine
    findings clear as five runs without a stall replace the ones that had it.

  • The a11y job's # observed annotation is regenerated (21m41s → 37m21s, the median of
    its last five runs on main; the job has grown with the gates added since it was written).

  • The kiosk's "Exit" link shows a readable focus ring on the day sheet. The plate's own rule,
    .plate-ui a:focus-visible, reaches the links inside the mode bar but not the exit link, which
    carries the class itself, so it kept the app's green ring: 1.47:1 on the paper, readable only on
    the dark ground. The focus gate passed at 20:15 UTC (lamplight) and failed at 06:50 on the same
    code, because the kiosk picks its palette from the sun and the route list held one bare /kiosk.
    The selector now covers it, and the browser gates list /kiosk?night=0 and /kiosk?night=1, so
    both palettes are graded on every run whatever the hour.


Installation

One-line installer (Raspberry Pi / bare-metal Linux)

curl --proto '=https' --tlsv1.2 -fsSL https://github.com/tomtom215/BirdNet-Behavior/releases/download/v0.19.0/install.sh | sudo bash -s -- --version 0.19.0

Run 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.19.0

The 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.19.0/

The binary serves the same manual offline at /help.

Verification

Checksums

sha256sum -c SHA256SUMS --ignore-missing

SLSA 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.19.0-aarch64-unknown-linux-gnu.tar.gz

Software 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.19.0-sbom.cdx.json
  • birdnet-behavior-0.19.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.