An outdoor sensor array for recording ecological activity with an indoor display and web based analytics view
10
stars
308
commits
Python
primary language
Sep 10, 2026
updated
A passive acoustic observatory for a garden, built on a Raspberry Pi 5 and an AudioMoth USB microphone. It listens continuously and keeps a record of what it heard, along with how confident it is about each identification.
The idea came from lying in a hammock using the Merlin Bird ID app to work out what was singing, and wanting that running all the time rather than only when someone was holding a phone.
Capture runs at 384 kHz so that bats are inside the band, and a 48 kHz audible stream is derived from the same frames. Three detectors run over immutable time-addressed windows, and detections that warrant it are stored with a checksummed evidence clip cut from the native ring buffer. Everything runs locally: capture, detection, review and query never need the internet.
A station is configured through its own web UI, and can be watched from a browser or from an ESP32 counter-top display that the station updates over the air. It also exposes a REST/WebSocket API, an optional MQTT feed into Home Assistant, and Prometheus metrics.
It runs unattended as a systemd service.
docs/delivery/MILESTONE_STATUS.md records
what is delivered, what is outstanding, and which acceptance criteria have not
yet been met.
New here? docs/README.md is the map of all the
documentation. If you are about to write code,
docs/development/SETUP.md first: it lists the
setup traps that will otherwise cost you an hour.
All three are a real station running on real hardware, not mockups. The station name is the only thing edited.

Two spectrograms, stacked so their frequency axes form one continuous run from
100 Hz to 150 kHz. Each panel states the parameters it is actually drawing with
(15 kHz–150 kHz, 128 bins, 24 ms/col, FFT 4096), because a spectrogram
with undeclared settings is a picture, not a measurement.
Below them, candidates carry the score as a number, the detector that said it, and the time. Note what the footer says: levels are dBFS relative to digital full scale, not calibrated SPL; scores are model outputs, not probabilities, unless a detector declares calibration. See Honesty rules for why those two sentences are there.

Capture coverage sits above the timeline, not beside it. 99.8% captured · 23h 56m from the microphone · 545 gaps · 22 streams is the first thing you
read, because an empty hour means something completely different depending on
whether nothing called or nothing was recording. Distinguishing a quiet night
from a dead microphone is a first-class requirement here, not a diagnostic
nicety.
The purple/green split is bats against birds, and it shows the thing you would
hope to see: bats confined to the dark hours, birds bracketing them with a dawn
peak. The caption under the chart (counts of detections, not of animals)
exists because one woodpigeon calling repeatedly produces 2,467 of them.
Engine appears in the species table as a non-taxonomic class, which is the
system declining to call a passing car a bird.

An ESP32 with a 2.8" touchscreen, on the same WiFi as the station, showing what is in the garden right now. Since the normal state of a working observatory is nobody at a browser, this is treated as a first-class surface rather than an accessory, and the web UI is the one you open when you want to dig into something.
It shows no scores, and only identifications above a confidence threshold, since a number sitting in a room invites a reading it cannot support. It also has to look unreachable when it cannot reach the station rather than merely quiet, because a stale list that looks fresh is the worst thing this surface can do.
activity-v1 — band-limited onset detection. No model, no downloads, no
taxonomic claim. Works out of the box.birdnet-v2.4 — BirdNET GLOBAL 6K V2.4, ~40× realtime on a Pi 5. Model assets
are not bundled. oo models fetch installs them with checksums and licences shown.ultrasonic-pass-v1 — bat pass detection on the native stream. Pulse trains
and peak frequency, explicitly not a species identification.Engine, Human vocal, Dog, …)
are stored as acoustic events with no rank and no scientific name, and a
human-voice detection gets a row and no audio at all (ADR-049).
clip_human_audio turns the second half off. It defaults to false and makes
you acknowledge a warning first.config/runtime.env on the device, atomically,
preserving your comments, so a hand edit and a UI edit are one configuration.refinement rows and can never rewrite a
detection's claim. Capture keeps cores 0–1 to itself.On the Pi:
sudo apt install -y build-essential python3-dev python3-venv libasound2-dev \
alsa-utils ffmpeg libsndfile1
python3.12 -m venv .venv # 3.12 exactly; pyproject requires >=3.12,<3.14
.venv/bin/pip install -e '.[alsa,resample,birdnet,dev]'
.venv/bin/oo audio probe # what is attached, and what it actually supports
.venv/bin/oo models fetch # optional: BirdNET assets (CC BY-NC-SA 4.0)
.venv/bin/oo serve # capture + detectors + API + UI on :8080
From a workstation, build the UI, sync, migrate and restart in one step:
HOST=<user>@<station-host> ./deploy/deploy.sh
HOST is required: this repository ships no station address (ADR-047). The
script runs alembic upgrade head against the still-running old version before
it restarts anything, so a failing migration leaves the working service up.
Then open http://<station-host>:8080 and press settings. Everything an
operator tunes lives there, and a terminal is not part of the loop.
No microphone? That is a supported mode, not a failure — the audio pipeline spec makes replay mandatory:
oo serve --source synthetic # generated dawn chorus / bat passes / sweeps
OO_REPLAY_PATH=recording.wav oo serve --source replay
The UI shows a loud red NOT LIVE AUDIO banner whenever the stream is not the real microphone, because a synthetic stream looks entirely normal in a spectrogram.
Design inspiration is the Merlin Bird ID app that started all this. Spectrogram on top, ranked candidates below it, the current one highlighted. The extra screen space goes on what a diagnostic surface needs and a product dashboard would hide (ADR-011).
Two live spectrograms, in either of two views. Audible 80 Hz–15 kHz, and ultrasonic 15–150 kHz when the native rate supports it. Log-frequency, adjustable history, three palettes (including a Merlin-style greyscale), adjustable range.
The panels are ordered so their frequency axes form one continuous run either way: ultrasound above audible in scroll, audible left of ultrasound in waterfall.
Best suggestions. Species and events grouped or as a timeline, with the score as a number, which detector said so, and a clip to check it against. A score is never called a probability unless the detector declares itself calibrated.
GO LIVE. Low-latency listening, measured at ~180 ms end to end, with monitor gain and a limiter, plus buffer/underrun/latency telemetry on display.
Pipeline panels. Capture continuity, device clock offset, hot-path CPU, resampler timing, ring-buffer fill and extraction misses, per-detector queue depth and lag, clip policy decisions, disk budget, lease balance, bus drops.
Event stream. Every capture.*, window.*, detection.*, clip.* and
health.* event, filterable and pausable.
HISTORY mode. The live channel only knows the session it is connected for, so there is a second mode that reads what was persisted. Named windows - last night, dawn chorus, yesterday - resolved in the station's own timezone. A timeline of detections per bucket split by group, what was identified and when it called, and clips playable from any of it. Click a bucket or a species to focus the list on it.
Capture coverage is shown above the timeline, because an empty window means something completely different depending on whether nothing called or nothing was recording. Aggregation happens in SQL: a night holds around 170,000 activity detections, and the browser is sent a few hundred numbers rather than all of them.
| Command | What it does |
|---|---|
oo audio probe | Enumerate capture devices; record formats, stable identity and native rate support |
oo audio test-capture | Capture briefly and report frames delivered vs elapsed, levels and clipping |
oo audio resample-check | Verify group delay, delivery-latency bounds and seam continuity (--json for a machine-readable result) |
oo audio window-dump | Inspect one segmenter window against ground-truth frame numbers, with optional injected gaps |
oo audiomoth info | Firmware identity over USB HID (switch in USB/OFF) |
oo models status / fetch | Model asset state and checksummed acquisition |
oo history reconcile-streams | Repair stream rows whose claimed span the frame count contradicts (ADR-024). Dry-run by default |
oo detections reconcile-plausibility | Re-check stored BirdNET rows against the current range model (ADR-032). Dry-run by default |
oo detections reconcile-taxonomy | Stop stored sound categories claiming to be birds at species rank (ADR-049). Dry-run by default |
oo clips purge-human-audio | Delete stored clips of human speech and mark the assets reclaimed (ADR-049). Dry-run by default |
oo clips retention | Run the tiered clip retention sweep by hand (ADR-026) |
oo refine run / status | One overnight refinement pass, and what the refiner has and has not examined (ADR-045) |
oo system-report | Host facts worth recording with a diagnostic |
oo serve | Run the station |
oo config | Print effective configuration |
The four repair commands (three reconcile-* and purge-human-audio) are
dry-run by default and need both --apply and a confirmation. None has been run
with --apply against the live station.
Only the capture service opens the ALSA device. It publishes immutable time-addressed windows. Audible detectors get the derived 48 kHz stream, ultrasonic detectors get the native high-rate stream. Evidence is always cut from the native ring buffer.
AudioMoth 384 kHz ──▶ capture ──▶ native ring (120 s) ──▶ evidence clips
│ ▲
├──▶ soxr 1/8 ──▶ audible ring
│ │
│ ├──▶ spectrogram ──┐
├──▶ spectrogram (ultrasonic)┤──▶ WebSocket ──▶ debug UI
│ └──▶ live audio ───┘
│
└──▶ segmenter ──▶ windows ──▶ detector workers
│
normaliser ◀──────┘
│
SQLite/PostgreSQL + clips ──▶ REST API
Beyond the debug UI, the same API feeds an ESP32 counter-top display in the
house. That is firmware/inside-observer/ - a pushed WebSocket at ~11 B/s with
an HTTP poller as fallback, never a score on the wire, and its own firmware
updated over the air (ADR-023/038/050). There is also an optional MQTT publisher with Home Assistant
Discovery (src/open_observatory/mqtt/, off by default, ADR-025). An
authentication foundation exists and is also off by default (ADR-034).
Two things run outside oo serve, on purpose. The refinement runner
(src/open_observatory/refinement/, ADR-045) is its own systemd service on a
timer, fenced to cores 2–3, so a 2-second inference pass can never starve the
capture loop, and the station process does not import it. The web build happens
on the workstation, because the Pi has no Node toolchain and does not need one.
docs/README.md is the full map. If you want a reading order:
docs/development/SETUP.md — get it running, and the trapsCLAUDE.md — the operating briefdocs/delivery/MILESTONE_STATUS.md — what is and is not donedocs/architecture/ADRS.md — the index of every decision and deviation, with status; one file per ADR under docs/architecture/adr/docs/operations/TARGET_DIAGNOSTICS.md — measured hardware factsdocs/delivery/HANDOVER.md — operational traps and the next-steps listdocs/api/DEBUG_UI_TRANSPORT.md — the live protocoldocs/operations/AUDIOMOTH_FIRMWARE.md — switch positions, firmware, gainThe original product and architecture specifications are kept unedited under
docs/product/ and docs/architecture/TECHNICAL_SPEC.md, each with a header
naming where the built system diverges from it.
.venv/bin/python -m pytest -q
( cd web && npm ci && npm test )
Some tests skip rather than fail by design: the fixture tests for the
deliberately-unbundled BirdNET and BatDetect2 model assets, and a few
tests/test_api.py::TestLiveChannels cases that starlette's synchronous
TestClient cannot represent. mypy src is not clean and never has been.
The Python tests run without a microphone, against the mandated replay and
synthetic sources. tests/test_api.py drives the real FastAPI app over the real
pipeline end to end. See
docs/development/SETUP.md for the setup traps and
the current expected counts.
The frontend tests cover the display geometry, which is where a bug is most dangerous: a view that puts a sound at the wrong frequency or time produces confident, wrong conclusions. Both orientations are asserted against the same properties, so adding the second view cannot silently break the first.
The code here is almost entirely AI-authored. The thinking behind it is not.
That division is deliberate and worth stating plainly, because it changes how you should read the repository.
Human — the concept and why it exists; the product design and what the
thing is for, the system architecture and how the pieces divide, the
priorities and what wins when they conflict (see
docs/CHARTER.md), what "tested" has to mean
(docs/development/TEST_PLAN.md), the hardware
choices, and continual review, direction and correction throughout. Every
significant decision was made, or accepted, by a person who understood the
system as a whole.
AI — nearly all of the implementation. The Python, the TypeScript, the
firmware, the tests, and most of the prose in docs/, written under direction
and reviewed.
It explains the shape of the repository. The density of Architecture Decision
Records, the measured figures attached to most claims, and the explicit lists of
what is not verified exist because AI-written code is confidently plausible by
default, and plausibility is not correctness. It also needs a different kind of
review: it fails less often at syntax and more often at assumptions, such as a
test asserting an invented value rather than the one the system really emits, or
a metric measuring something adjacent to what its name claims. Those failures
are quiet and look like success. HANDOVER.md records several that were caught
only by checking a claim against the thing it described.
The bar is evidence, not authorship. It does not matter whether a change was
written by a person or a model. It matters whether the claims attached to it
were verified, and whether the things that could not be verified are stated as
such. docs/development/TEST_PLAN.md sets out
what that requires, and it opens with the bugs that passed their tests first,
because that is the failure mode this project is built to resist.
taxonomic_group: acoustic_event, because a classifier
saying Engine is not the classifier identifying a bird (ADR-049).amplitudes_comparable_to_native: false and the UI marks them "processed". Only
the native clip is evidence of level./api/v1/health.This is the reference station: the one every measured figure in these documents
came from. Nothing here is required by the software: the station discovers what
it is attached to and records what it actually negotiated (oo audio probe).
It is listed because "what did you build it out of" is the first question
anybody asks, and because a figure means more when you know what produced it.
| What | Notes | |
|---|---|---|
| Computer | Raspberry Pi 5 Model B Rev 1.1, 8 GB | Ubuntu 24.04 LTS, aarch64 |
| Case | Flirc Raspberry Pi 5 case | Passive; the aluminium body is the heatsink. No fan. Idles around 39 °C with capture and three detectors running |
| Power | 5 V 3 A USB charger | An iPad charger, chosen as a known-good supply. See the note below — this is under the Pi 5's rated 5 A |
| System storage | SanDisk 256 GB microSD | OS, application and the SQLite database |
| Evidence storage | SanDisk Extreme Portable SSD, 500 GB (0781:558c) | USB, UAS. Mounted over data/clips; carries clips only, deliberately not the database (ADR-021) |
| Microphone | AudioMoth USB Microphone (16d0:06f3) | A dedicated variant of the AudioMoth 1.2.0 design rather than a recorder running different firmware. Negotiates 384 kHz mono S16_LE here |
| Microphone case | Official AudioMoth USB Microphone case | |
| Microphone cable | Anker 2 m micro-USB | Long enough to reach the eaves from indoors |
| Display | ESP32-2432S028R ("Cheap Yellow Display") | 2.8" 240×320 ILI9341, XPT2046 resistive touch. See firmware/inside-observer/ |
| Display case | Printed case | From the Aura project's own build, which this board was assembled for |
| Network | WiFi | The Pi's Ethernet port is unused |
Almost all of this was already lying around. The Pi and its case were spares from an abandoned project, the SSD came out of a retired k3s cluster of Pi 4s, the display had been built as a weather forecaster, and the power supply is an iPad charger picked because it was known to be good. The microphone is the only part bought for the job.
That is worth knowing because several of the constraints described below follow from it rather than from any design decision: a 600 mA USB budget, a database on an SD card, and a display holding WiFi credentials from its previous life.
The display's history in particular shapes the firmware. The board was assembled as Aura, an open-source smart weather-forecast display for this exact board and not a project of ours, then went spare when a TRMNL replaced it. The case, the assembly and the WiFi credentials sitting in NVS all predate this repository.
That inheritance explains three decisions that would otherwise look arbitrary:
the partition table preserves the original NVS region byte for byte, because it
holds credentials nobody here has ever seen and cannot retype. A complete 4 MB
image was taken before anything was written, so the board can be returned to
Aura. And the provisioning access point was called Aura until it was renamed
to something per-device (ADR-050).
Siting. The Pi lives indoors in a summer house at the end of the garden. The microphone hangs on a hook under the eaves, on the 2 m cable run out through the window jamb. That arrangement is why the station hears the garden and not the room, and why the microphone's exact position has a larger effect on the data than any setting in this repository. Moving it a few feet changed the noise floor materially.
A lux sensor and a rain sensor, to say whether a detection happened in real
darkness rather than calculated darkness, and to explain the hours where rain
lifts the noise floor and quietens the birds. How to attach them is undecided —
see Milestone 9 in
docs/delivery/IMPLEMENTATION_PLAN.md.
DEFAULT streams audio;
USB/OFF is configuration only and produces no ALSA card at all. Setting it to
USB/OFF is exactly what caused a 29-hour outage during commissioning.usb_max_current_enable=0, which caps total USB current
at 600 mA, shared here between an SSD and a microphone. It has not caused a
fault: vcgencmd get_throttled reads 0x0, meaning no undervoltage has ever
been recorded on this station. It is listed because an underpowered supply is a
genuinely plausible cause of an intermittently-enumerating microphone, and
because anyone reproducing this should make the choice knowingly rather than
inherit it. A 27 W supply removes the constraint.Apache-2.0 for this code. Third-party model assets carry their own terms: BirdNET's
released models are CC BY-NC-SA 4.0, which prohibits commercial use. See
/api/v1/models on a running station for what is installed and under what terms.
304 commits
4 commits
Python
74.4%
TypeScript
15.6%
C++
8.1%
CSS
1.6%
An outdoor sensor array for recording ecological activity with an indoor display and web based analytics view
10
stars
308
commits
Python
primary language
Sep 10, 2026
updated
A passive acoustic observatory for a garden, built on a Raspberry Pi 5 and an AudioMoth USB microphone. It listens continuously and keeps a record of what it heard, along with how confident it is about each identification.
The idea came from lying in a hammock using the Merlin Bird ID app to work out what was singing, and wanting that running all the time rather than only when someone was holding a phone.
Capture runs at 384 kHz so that bats are inside the band, and a 48 kHz audible stream is derived from the same frames. Three detectors run over immutable time-addressed windows, and detections that warrant it are stored with a checksummed evidence clip cut from the native ring buffer. Everything runs locally: capture, detection, review and query never need the internet.
A station is configured through its own web UI, and can be watched from a browser or from an ESP32 counter-top display that the station updates over the air. It also exposes a REST/WebSocket API, an optional MQTT feed into Home Assistant, and Prometheus metrics.
It runs unattended as a systemd service.
docs/delivery/MILESTONE_STATUS.md records
what is delivered, what is outstanding, and which acceptance criteria have not
yet been met.
New here? docs/README.md is the map of all the
documentation. If you are about to write code,
docs/development/SETUP.md first: it lists the
setup traps that will otherwise cost you an hour.
All three are a real station running on real hardware, not mockups. The station name is the only thing edited.

Two spectrograms, stacked so their frequency axes form one continuous run from
100 Hz to 150 kHz. Each panel states the parameters it is actually drawing with
(15 kHz–150 kHz, 128 bins, 24 ms/col, FFT 4096), because a spectrogram
with undeclared settings is a picture, not a measurement.
Below them, candidates carry the score as a number, the detector that said it, and the time. Note what the footer says: levels are dBFS relative to digital full scale, not calibrated SPL; scores are model outputs, not probabilities, unless a detector declares calibration. See Honesty rules for why those two sentences are there.

Capture coverage sits above the timeline, not beside it. 99.8% captured · 23h 56m from the microphone · 545 gaps · 22 streams is the first thing you
read, because an empty hour means something completely different depending on
whether nothing called or nothing was recording. Distinguishing a quiet night
from a dead microphone is a first-class requirement here, not a diagnostic
nicety.
The purple/green split is bats against birds, and it shows the thing you would
hope to see: bats confined to the dark hours, birds bracketing them with a dawn
peak. The caption under the chart (counts of detections, not of animals)
exists because one woodpigeon calling repeatedly produces 2,467 of them.
Engine appears in the species table as a non-taxonomic class, which is the
system declining to call a passing car a bird.

An ESP32 with a 2.8" touchscreen, on the same WiFi as the station, showing what is in the garden right now. Since the normal state of a working observatory is nobody at a browser, this is treated as a first-class surface rather than an accessory, and the web UI is the one you open when you want to dig into something.
It shows no scores, and only identifications above a confidence threshold, since a number sitting in a room invites a reading it cannot support. It also has to look unreachable when it cannot reach the station rather than merely quiet, because a stale list that looks fresh is the worst thing this surface can do.
activity-v1 — band-limited onset detection. No model, no downloads, no
taxonomic claim. Works out of the box.birdnet-v2.4 — BirdNET GLOBAL 6K V2.4, ~40× realtime on a Pi 5. Model assets
are not bundled. oo models fetch installs them with checksums and licences shown.ultrasonic-pass-v1 — bat pass detection on the native stream. Pulse trains
and peak frequency, explicitly not a species identification.Engine, Human vocal, Dog, …)
are stored as acoustic events with no rank and no scientific name, and a
human-voice detection gets a row and no audio at all (ADR-049).
clip_human_audio turns the second half off. It defaults to false and makes
you acknowledge a warning first.config/runtime.env on the device, atomically,
preserving your comments, so a hand edit and a UI edit are one configuration.refinement rows and can never rewrite a
detection's claim. Capture keeps cores 0–1 to itself.On the Pi:
sudo apt install -y build-essential python3-dev python3-venv libasound2-dev \
alsa-utils ffmpeg libsndfile1
python3.12 -m venv .venv # 3.12 exactly; pyproject requires >=3.12,<3.14
.venv/bin/pip install -e '.[alsa,resample,birdnet,dev]'
.venv/bin/oo audio probe # what is attached, and what it actually supports
.venv/bin/oo models fetch # optional: BirdNET assets (CC BY-NC-SA 4.0)
.venv/bin/oo serve # capture + detectors + API + UI on :8080
From a workstation, build the UI, sync, migrate and restart in one step:
HOST=<user>@<station-host> ./deploy/deploy.sh
HOST is required: this repository ships no station address (ADR-047). The
script runs alembic upgrade head against the still-running old version before
it restarts anything, so a failing migration leaves the working service up.
Then open http://<station-host>:8080 and press settings. Everything an
operator tunes lives there, and a terminal is not part of the loop.
No microphone? That is a supported mode, not a failure — the audio pipeline spec makes replay mandatory:
oo serve --source synthetic # generated dawn chorus / bat passes / sweeps
OO_REPLAY_PATH=recording.wav oo serve --source replay
The UI shows a loud red NOT LIVE AUDIO banner whenever the stream is not the real microphone, because a synthetic stream looks entirely normal in a spectrogram.
Design inspiration is the Merlin Bird ID app that started all this. Spectrogram on top, ranked candidates below it, the current one highlighted. The extra screen space goes on what a diagnostic surface needs and a product dashboard would hide (ADR-011).
Two live spectrograms, in either of two views. Audible 80 Hz–15 kHz, and ultrasonic 15–150 kHz when the native rate supports it. Log-frequency, adjustable history, three palettes (including a Merlin-style greyscale), adjustable range.
The panels are ordered so their frequency axes form one continuous run either way: ultrasound above audible in scroll, audible left of ultrasound in waterfall.
Best suggestions. Species and events grouped or as a timeline, with the score as a number, which detector said so, and a clip to check it against. A score is never called a probability unless the detector declares itself calibrated.
GO LIVE. Low-latency listening, measured at ~180 ms end to end, with monitor gain and a limiter, plus buffer/underrun/latency telemetry on display.
Pipeline panels. Capture continuity, device clock offset, hot-path CPU, resampler timing, ring-buffer fill and extraction misses, per-detector queue depth and lag, clip policy decisions, disk budget, lease balance, bus drops.
Event stream. Every capture.*, window.*, detection.*, clip.* and
health.* event, filterable and pausable.
HISTORY mode. The live channel only knows the session it is connected for, so there is a second mode that reads what was persisted. Named windows - last night, dawn chorus, yesterday - resolved in the station's own timezone. A timeline of detections per bucket split by group, what was identified and when it called, and clips playable from any of it. Click a bucket or a species to focus the list on it.
Capture coverage is shown above the timeline, because an empty window means something completely different depending on whether nothing called or nothing was recording. Aggregation happens in SQL: a night holds around 170,000 activity detections, and the browser is sent a few hundred numbers rather than all of them.
| Command | What it does |
|---|---|
oo audio probe | Enumerate capture devices; record formats, stable identity and native rate support |
oo audio test-capture | Capture briefly and report frames delivered vs elapsed, levels and clipping |
oo audio resample-check | Verify group delay, delivery-latency bounds and seam continuity (--json for a machine-readable result) |
oo audio window-dump | Inspect one segmenter window against ground-truth frame numbers, with optional injected gaps |
oo audiomoth info | Firmware identity over USB HID (switch in USB/OFF) |
oo models status / fetch | Model asset state and checksummed acquisition |
oo history reconcile-streams | Repair stream rows whose claimed span the frame count contradicts (ADR-024). Dry-run by default |
oo detections reconcile-plausibility | Re-check stored BirdNET rows against the current range model (ADR-032). Dry-run by default |
oo detections reconcile-taxonomy | Stop stored sound categories claiming to be birds at species rank (ADR-049). Dry-run by default |
oo clips purge-human-audio | Delete stored clips of human speech and mark the assets reclaimed (ADR-049). Dry-run by default |
oo clips retention | Run the tiered clip retention sweep by hand (ADR-026) |
oo refine run / status | One overnight refinement pass, and what the refiner has and has not examined (ADR-045) |
oo system-report | Host facts worth recording with a diagnostic |
oo serve | Run the station |
oo config | Print effective configuration |
The four repair commands (three reconcile-* and purge-human-audio) are
dry-run by default and need both --apply and a confirmation. None has been run
with --apply against the live station.
Only the capture service opens the ALSA device. It publishes immutable time-addressed windows. Audible detectors get the derived 48 kHz stream, ultrasonic detectors get the native high-rate stream. Evidence is always cut from the native ring buffer.
AudioMoth 384 kHz ──▶ capture ──▶ native ring (120 s) ──▶ evidence clips
│ ▲
├──▶ soxr 1/8 ──▶ audible ring
│ │
│ ├──▶ spectrogram ──┐
├──▶ spectrogram (ultrasonic)┤──▶ WebSocket ──▶ debug UI
│ └──▶ live audio ───┘
│
└──▶ segmenter ──▶ windows ──▶ detector workers
│
normaliser ◀──────┘
│
SQLite/PostgreSQL + clips ──▶ REST API
Beyond the debug UI, the same API feeds an ESP32 counter-top display in the
house. That is firmware/inside-observer/ - a pushed WebSocket at ~11 B/s with
an HTTP poller as fallback, never a score on the wire, and its own firmware
updated over the air (ADR-023/038/050). There is also an optional MQTT publisher with Home Assistant
Discovery (src/open_observatory/mqtt/, off by default, ADR-025). An
authentication foundation exists and is also off by default (ADR-034).
Two things run outside oo serve, on purpose. The refinement runner
(src/open_observatory/refinement/, ADR-045) is its own systemd service on a
timer, fenced to cores 2–3, so a 2-second inference pass can never starve the
capture loop, and the station process does not import it. The web build happens
on the workstation, because the Pi has no Node toolchain and does not need one.
docs/README.md is the full map. If you want a reading order:
docs/development/SETUP.md — get it running, and the trapsCLAUDE.md — the operating briefdocs/delivery/MILESTONE_STATUS.md — what is and is not donedocs/architecture/ADRS.md — the index of every decision and deviation, with status; one file per ADR under docs/architecture/adr/docs/operations/TARGET_DIAGNOSTICS.md — measured hardware factsdocs/delivery/HANDOVER.md — operational traps and the next-steps listdocs/api/DEBUG_UI_TRANSPORT.md — the live protocoldocs/operations/AUDIOMOTH_FIRMWARE.md — switch positions, firmware, gainThe original product and architecture specifications are kept unedited under
docs/product/ and docs/architecture/TECHNICAL_SPEC.md, each with a header
naming where the built system diverges from it.
.venv/bin/python -m pytest -q
( cd web && npm ci && npm test )
Some tests skip rather than fail by design: the fixture tests for the
deliberately-unbundled BirdNET and BatDetect2 model assets, and a few
tests/test_api.py::TestLiveChannels cases that starlette's synchronous
TestClient cannot represent. mypy src is not clean and never has been.
The Python tests run without a microphone, against the mandated replay and
synthetic sources. tests/test_api.py drives the real FastAPI app over the real
pipeline end to end. See
docs/development/SETUP.md for the setup traps and
the current expected counts.
The frontend tests cover the display geometry, which is where a bug is most dangerous: a view that puts a sound at the wrong frequency or time produces confident, wrong conclusions. Both orientations are asserted against the same properties, so adding the second view cannot silently break the first.
The code here is almost entirely AI-authored. The thinking behind it is not.
That division is deliberate and worth stating plainly, because it changes how you should read the repository.
Human — the concept and why it exists; the product design and what the
thing is for, the system architecture and how the pieces divide, the
priorities and what wins when they conflict (see
docs/CHARTER.md), what "tested" has to mean
(docs/development/TEST_PLAN.md), the hardware
choices, and continual review, direction and correction throughout. Every
significant decision was made, or accepted, by a person who understood the
system as a whole.
AI — nearly all of the implementation. The Python, the TypeScript, the
firmware, the tests, and most of the prose in docs/, written under direction
and reviewed.
It explains the shape of the repository. The density of Architecture Decision
Records, the measured figures attached to most claims, and the explicit lists of
what is not verified exist because AI-written code is confidently plausible by
default, and plausibility is not correctness. It also needs a different kind of
review: it fails less often at syntax and more often at assumptions, such as a
test asserting an invented value rather than the one the system really emits, or
a metric measuring something adjacent to what its name claims. Those failures
are quiet and look like success. HANDOVER.md records several that were caught
only by checking a claim against the thing it described.
The bar is evidence, not authorship. It does not matter whether a change was
written by a person or a model. It matters whether the claims attached to it
were verified, and whether the things that could not be verified are stated as
such. docs/development/TEST_PLAN.md sets out
what that requires, and it opens with the bugs that passed their tests first,
because that is the failure mode this project is built to resist.
taxonomic_group: acoustic_event, because a classifier
saying Engine is not the classifier identifying a bird (ADR-049).amplitudes_comparable_to_native: false and the UI marks them "processed". Only
the native clip is evidence of level./api/v1/health.This is the reference station: the one every measured figure in these documents
came from. Nothing here is required by the software: the station discovers what
it is attached to and records what it actually negotiated (oo audio probe).
It is listed because "what did you build it out of" is the first question
anybody asks, and because a figure means more when you know what produced it.
| What | Notes | |
|---|---|---|
| Computer | Raspberry Pi 5 Model B Rev 1.1, 8 GB | Ubuntu 24.04 LTS, aarch64 |
| Case | Flirc Raspberry Pi 5 case | Passive; the aluminium body is the heatsink. No fan. Idles around 39 °C with capture and three detectors running |
| Power | 5 V 3 A USB charger | An iPad charger, chosen as a known-good supply. See the note below — this is under the Pi 5's rated 5 A |
| System storage | SanDisk 256 GB microSD | OS, application and the SQLite database |
| Evidence storage | SanDisk Extreme Portable SSD, 500 GB (0781:558c) | USB, UAS. Mounted over data/clips; carries clips only, deliberately not the database (ADR-021) |
| Microphone | AudioMoth USB Microphone (16d0:06f3) | A dedicated variant of the AudioMoth 1.2.0 design rather than a recorder running different firmware. Negotiates 384 kHz mono S16_LE here |
| Microphone case | Official AudioMoth USB Microphone case | |
| Microphone cable | Anker 2 m micro-USB | Long enough to reach the eaves from indoors |
| Display | ESP32-2432S028R ("Cheap Yellow Display") | 2.8" 240×320 ILI9341, XPT2046 resistive touch. See firmware/inside-observer/ |
| Display case | Printed case | From the Aura project's own build, which this board was assembled for |
| Network | WiFi | The Pi's Ethernet port is unused |
Almost all of this was already lying around. The Pi and its case were spares from an abandoned project, the SSD came out of a retired k3s cluster of Pi 4s, the display had been built as a weather forecaster, and the power supply is an iPad charger picked because it was known to be good. The microphone is the only part bought for the job.
That is worth knowing because several of the constraints described below follow from it rather than from any design decision: a 600 mA USB budget, a database on an SD card, and a display holding WiFi credentials from its previous life.
The display's history in particular shapes the firmware. The board was assembled as Aura, an open-source smart weather-forecast display for this exact board and not a project of ours, then went spare when a TRMNL replaced it. The case, the assembly and the WiFi credentials sitting in NVS all predate this repository.
That inheritance explains three decisions that would otherwise look arbitrary:
the partition table preserves the original NVS region byte for byte, because it
holds credentials nobody here has ever seen and cannot retype. A complete 4 MB
image was taken before anything was written, so the board can be returned to
Aura. And the provisioning access point was called Aura until it was renamed
to something per-device (ADR-050).
Siting. The Pi lives indoors in a summer house at the end of the garden. The microphone hangs on a hook under the eaves, on the 2 m cable run out through the window jamb. That arrangement is why the station hears the garden and not the room, and why the microphone's exact position has a larger effect on the data than any setting in this repository. Moving it a few feet changed the noise floor materially.
A lux sensor and a rain sensor, to say whether a detection happened in real
darkness rather than calculated darkness, and to explain the hours where rain
lifts the noise floor and quietens the birds. How to attach them is undecided —
see Milestone 9 in
docs/delivery/IMPLEMENTATION_PLAN.md.
DEFAULT streams audio;
USB/OFF is configuration only and produces no ALSA card at all. Setting it to
USB/OFF is exactly what caused a 29-hour outage during commissioning.usb_max_current_enable=0, which caps total USB current
at 600 mA, shared here between an SSD and a microphone. It has not caused a
fault: vcgencmd get_throttled reads 0x0, meaning no undervoltage has ever
been recorded on this station. It is listed because an underpowered supply is a
genuinely plausible cause of an intermittently-enumerating microphone, and
because anyone reproducing this should make the choice knowingly rather than
inherit it. A 27 W supply removes the constraint.Apache-2.0 for this code. Third-party model assets carry their own terms: BirdNET's
released models are CC BY-NC-SA 4.0, which prohibits commercial use. See
/api/v1/models on a running station for what is installed and under what terms.
304 commits
4 commits
Python
74.4%
TypeScript
15.6%
C++
8.1%
CSS
1.6%