DEV Community

Cover image for I fed a global bird model foreign audio. It was confidently wrong. Then I fixed it.
Melwin Siril Joel V
Melwin Siril Joel V

Posted on AI-assisted

I fed a global bird model foreign audio. It was confidently wrong. Then I fixed it.

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass

What I Built

I started this project because I noticed something wrong with how bird identification works in my own town.

BirdNET is a genuinely impressive open-source project. Point it at a recording and it will confidently tell you what you heard. I tried it on some audio recorded on my hostel campus in Chidambaram, Tamil Nadu, and it told me about Rose-ringed Parakeet, Common Grackle, and Dark-eyed Junco. The grackle is a North American species. It has never been in India. It was never in my garden. The model was not confused - it was sure, and wrong.

That is the whole problem in one clip. A global classifier trained on global audio will happily tell you about birds that live four thousand kilometres away, because nothing in its architecture knows or cares where you are standing. There is no "not in my region" button.

chidambaram-birdnudge is that button. It runs the same open weights on your own machine, then filters every prediction through a species list built from real observations near you. It costs nothing to run, needs no account, works with no signal, and never sends your recordings anywhere.

A local Node.js pipeline that takes phone recordings, identifies birds with an open-weight audio model, filters the results to a regional species list, and tells you where to go next.

The pipeline is five scripts:

script what it does
download-model.mjs fetches the BirdNET+ weights and label set
fetch-pack.mjs builds the local species list from iNaturalist observations within a radius
convert.mjs batch-converts .m4a / .mp3 / .ogg to the 32 kHz mono WAV the model wants
analyze.mjs runs ONNX inference, writes a detection CSV per clip
summarize.mjs scores predictions against the pack, writes docs/overview.md
case.mjs generates the case study from the detection data

Two design decisions are worth calling out.

The model runs whole-file peak-normalised, so a quiet outdoor clip and a loud indoor one are comparable. This matters more than it sounds - my outdoor clip was about 6 dB quieter than my indoor ones, and without normalisation the comparison would have been meaningless.

min-conf defaults to 0.05, not 0.25. BirdNET's own web demo defaults much higher, which means it silently discards low-confidence-but-real detections. I lowered it so the local filter gets to see everything, then let the pack do the rejecting. The pack is a better filter than an arbitrary threshold, because it is specific to where you are.

Repo: https://github.com/Emanuelmsjv/chidambaram-birdnudge

Demo

Real output from a real run, on the five clips described above:

model    ...\assets\BirdNET+_V3.0-preview3.1_Global_11K_FP16_pruned.onnx
labels   11560 species
settings chunk=3s overlap=0s minConf=0.05

model load: 971ms
inside-2.wav  9.9s audio  0.4s compute  6 detections
    0.870  Yellow-billed Babbler  (Argya affinis)
    0.589  Rufous Treepie  (Dendrocitta vagabunda)
    0.068  Indian White-eye  (Zosterops palpebrosus)
inside.wav  17.9s audio  0.5s compute  15 detections
    0.812  Yellow-billed Babbler  (Argya affinis)
    0.415  Common Grackle  (Quiscalus quiscula)
    0.283  Oriental Magpie-Robin  (Copsychus saularis)
outside.wav  17.5s audio  0.5s compute  9 detections
    0.873  Rose-ringed Parakeet  (Psittacula krameri)
    0.731  Common Myna  (Acridotheres tristis)
    0.509  Red-vented Bulbul  (Pycnonotus cafer)

5 file(s), 47 detections
Enter fullscreen mode Exit fullscreen mode

And what the filter actually does with that output:

$ npm run summarize
Clips: 6  detections: 146
In-pack (top-5): 15/27 = 55.6%
In-pack (all 53 distinct): 24 = 45.3%
Enter fullscreen mode Exit fullscreen mode

Code

The whole pipeline is open source under CC BY-NC-SA 4.0. Everything below runs on your
own machine with Node 18+, no Python, no GPU, and no API keys.

GitHub logo Emanuelmsjv / chidambaram-birdnudge

Offline open-weight bird ID for Chidambaram, Tamil Nadu. BirdNET V3.0 via ONNX Runtime in pure Node - no Python, no GPU. Output is filtered against an iNaturalist-derived local species pack, and scored with a label-free measure of how plausible that output is for this location.

chidambaram-birdnudge

Point an open-weight bird sound model at a 10-second recording of somewhere in Chidambaram, Tamil Nadu. Get back one sentence telling you which species plausibly live near you, whether they are in season right now, what time of day they are worth listening for, and a real place within reach to go and stand.

The model already exists and is very good. It is also, on published evidence close to useless in this part of India. That gap is the project.

The premise

A 2026 global evaluation of BirdNET across 4,224 soundscapes, 67 sites and every continent, annotated by local experts, reported PR AUC of:

Region PR AUC
North America / Oceania / Europe 0.16 – 0.23
Central & South America 0.13
Africa and Asia 0.03 – 0.04

Chidambaram sits in that last band. Globally, the share of predictions that are correct (precision) is around 0.57–0.71, so roughly a…

Repo: https://github.com/Emanuelmsjv/chidambaram-birdnudge

A one-command setup fetches the 68 MB model weights and builds the local species pack:

git clone https://github.com/Emanuelmsjv/chidambaram-birdnudge
cd chidambaram-birdnudge
npm install
npm run setup        # downloads the 68 MB model, builds your species pack

npm run convert      # phone .m4a -> 32 kHz mono WAV
npm run analyze clips --out data/detections
npm run summarize    # writes docs/overview.md
Enter fullscreen mode Exit fullscreen mode
git clone https://github.com/Emanuelmsjv/chidambaram-birdnudge
cd chidambaram-birdnudge
npm install
npm run setup        # downloads the 68 MB model, builds your species pack

npm run convert      # phone .m4a -> 32 kHz mono WAV
npm run analyze clips --out data/detections
npm run summarize    # writes docs/overview.md
Enter fullscreen mode Exit fullscreen mode

Requires Node 18+ and ffmpeg on PATH. No Python, no GPU, no API keys.

Licence: CC BY-NC-SA 4.0. BirdNET weights are CC BY-NC-SA. iNaturalist observation data retains its own per-record licences, referenced in data/pack.json.

How I Built It

Model BirdNET+ V3.0-preview3.1 Global 11K FP16 (pruned)
Weights format ONNX, 68.2 MB
Runtime onnxruntime-node
Labels 11,560 species, plain CSV
Hardware CPU only. No GPU. No Python.

The model is a TarsNet2 classifier - a frozen convolutional backbone with a linear classification head. That architecture is the reason this project is tractable offline: the expensive part is fixed, and the part you retarget is one dense layer.

Speed: the five-clip case study below is 83.8 seconds of audio, analysed in under 4 seconds after a ~1 second model load. That is roughly 25x realtime on a laptop CPU with no accelerator.

Why Does Open Innovation Matter?

The challenge asks where an open approach did better than a closed one. I can answer that with a measurement rather than an opinion.

Same weights, same code, two recordings:

audio in-pack fraction
upstream BirdNET sample, recorded in New York 1 / 20
my recordings, Chidambaram 23 / 33

Nineteen of the twenty species the model proposed for the New York audio do not exist within 30 km of Chidambaram. The twentieth is House Sparrow at 0.051 - rank 20 of 20 by confidence, barely clearing my own threshold. Every confident prediction was a bird that does not live here:

A hosted bird-ID service gives you a fixed species list, the one its provider chose. If that list does not contain your local birds, you cannot add them. With open weights I can change what the model is allowed to say. That is the entire difference between 1/20 and 23/33, and it is not available behind an API.

The other reasons the open pieces mattered:

It runs with no signal. After a one-time 68 MB download, npm run analyze never touches the network. A bird call identifier is only useful where bird calls happen, which is usually nowhere with good connectivity. I did not get to actually test this on a trail with no bars - I tested it on a laptop, and the mobile app I originally used needed a working connection. I am not claiming more than I measured.

Your recordings never leave your machine. The case study below is audio recorded at a university hostel. Uploading that to a third-party inference endpoint hands someone a location-precise recording of where a person lives and when they were there. Local inference means the audio is read once by decodeWav and never transmitted.

It costs nothing. No API key, no per-minute billing, no quota. A hosted audio-classification endpoint bills by the minute precisely when you most want to use it and least want to depend on it.

The model is replaceable. The labels are a CSV and the regional list is JSON. Retargeting to another city is a data edit, not a code change. Nothing about the pipeline is specific to BirdNET except the inference call.

Honest limits of the open approach

Being straightforward about where this does not win:

  • The weights are CC BY-NC-SA, so this is non-commercial. That is fine for a personal tool and a real constraint if you wanted to build a product.
  • Fine-tuning for a local species set is architecturally trivial here (frozen encoder, linear head, logistic regression on embeddings, trainable on CPU) but I did not do it. With four labelled clips and no working route to bulk training audio, any claim I made about it would be unverified.
  • BirdNET's Python pipeline has features my port does not - proper spectrogram caching, multi-label handling, and more careful segmenting. I ported the part that made my question answerable, not the whole project.

I took it outside

The brief offers "a bird call identifier that works on the trail with no signal" as an example. I took it literally.

I heard a group of Yellow-billed Babbler (Argya affinis) on my hostel campus and recorded five clips on my phone - two indoors, one outdoors, and two while I was talking, to see what happened. Then I ran them through the pipeline.

The species was already in my local pack at rank 18 of 130, with 19 iNaturalist observations in range - a locally well-established bird, not a rarity.

clip babblers audible? model's best score
inside-2 yes 0.870
inside yes 0.812
speech-2 yes 0.961
speech no none
outside yes none

Three of the four clips where I confirmed the bird was audible got a correct first-place identification.

The two I find most interesting are the speech pair. Both were recorded in the same room, minutes apart, with me talking. I listened back afterwards to check what was in the background. In one, babblers were chirping behind my voice; in the other, there were none. The model returned 0.961 for the one where they were there and nothing at all for the one where they were not.

That distinction matters, because the cheap explanation would have been that the model just fires on human voices and happened to point at a common local babbler. Two clips with identical foreground content and opposite answers rules that out.

The one result I still cannot fully explain is speech-2, where the babbler is buried under speech and still scored 0.961. I am reporting it because it happened, not because I understand it.

What it got wrong

Every one of these is in the repo, not just here.

It missed the bird entirely on the outdoor clip. I was there, the babblers were there, and the model proposed parakeet and myna instead. That clip was also my quietest recording. I still do not know whether the model failed or the microphone failed. It is the first thing I would try to fix.

Confidence is not a probability. BirdNET will hand you 0.9 and mean something much weaker. The New York control demonstrates this directly - the model was extremely confident about species that were not present at all. The number is a ranking signal, nothing more.

My species pack is biased, and I can show how. It is built from iNaturalist, which is a photography platform. I pulled 1,000 research-grade observations from around Chidambaram and checked the result: Indian White-eye appeared zero times. It is one of the most common birds in India and it is, to my ear, constantly audible from my window. The pack over-represents birds people stop to photograph - Pied Stilt is the second most-recorded species in range - and under-represents small common passerines nobody bothers to upload.

So "not in your local pack" is weaker evidence than it appears. It means not established as local by this data source, not absent. I would not have found this without checking the raw observations against my own pack.

I had a real bug for most of this project. The pack builder wrote entries as { rank: i + 1, ...s } - the spread lands after, so the taxonomic rank from iNaturalist overwrote my ordinal in all 130 entries. Every rank field was the string "species". The ordering was still correct, which is exactly why I did not notice for weeks. Fixed in a087f8f.

My recordings were not independent data. I collected them, then evaluated the model on them. There is no held-out set, and with four positives any accuracy number would be meaningless. I have not claimed one anywhere.

My Agent Session

Here is the session where the agent found the rank bug, in full. It is twelve messages: you can watch it notice that rank prints as "species" instead of a number, work out that { rank: i + 1, ...s } lets iNaturalist's own rank string land on top of the ordinal, and fix it. The interesting detail is that nothing was broken as far as anyone could tell - the pack was still sorted correctly the whole time. The bug only became visible because a species position was checked against a literal 18.

Debugging a clobbered rank field in fetch-pack.mjs (opencode)
You

what is observed is a group of Common Myna, where inside my hostel Golden Jubille hostels trees and outside of the hostel campus too. and im checking in the birdnet app ill update until then well completed debugging and furnishing and deploying too

Agent

Common Myna is a strong test case - and I can tell you something useful before you run it. Let me verify.

Agent

Common Myna confirmed in the pack - 23 observations, and notably the pack contains Brahminy Starling and Indian Pied Starling but no Common Starling. That matters. Let me confirm one suspicion:

Agent

Common Myna is in the pack (23 observations). But I need to be honest with you about the test case, because it matters for how the post reads.

Common Myna is the easiest possible test

It's one of the loudest, most distinctive, most-recorded birds in India - Xeno-canto is saturated with it. If BirdNET names it correctly, that tells you almost nothing about regional weakness. It just confirms the model works on an easy target, with n=1.

I marked it ubiquitous in the fieldnote pack precisely because it's the worst possible "go outside now" trigger - hearable from your desk all day. Good for detection testing, useless as a nudge.

So don't build the case study around "did it get the myna right." Build it around the tail: even on a correct detection, BirdNET emits 11,560 classes and a long junk tail above 0.05. The story is what else it said - the confident non-local predictions, and what fraction of its output was locally plausible. A correct top-1 with40 nonsense runners-up is a more interesting result than a clean single prediction, and it still supports the thesis.

Your indoor/outdoor split is genuinely useful - that gives acoustic contrast from the same species at the same location. Indoor hostel means ventilation noise and people; outdoor campus is open-air. That's a controlled comparison of exactly the variable I flagged as the risk.

Two real bugs found

1. rank is clobbered in all 130 pack entries - "species" instead of a number. I wrote { rank: i + 1, ...s } and the spread lands after, so iNat's own taxonomic rank field overwrites mine. Classic ordering bug, invisible because the array is still sorted correctly.

2. docs/overview.md is gitignored - the repo publishes zero results. Anyone cloning it sees a pipeline and a negative control, but no findings. Fieldnote got this right (docs/eval.md is committed); project 1 never got the equivalent.

Plan

Phase 1 - no audio needed, ~45 min
1. Fix the rank spread-order bug
2. Un-ignore docs/overview.md, commit results as a tracked artefact
3. Add the all-detections metric (higher count, lower variance - this is what makes a small n credible)

Phase 2 - needs your audio, ~1 hr
4. Install ffmpeg (Gyan.FFmpeg, winget)
5. scripts/convert.mjs - batch convert a folder, one command
6. scripts/case.mjs - produces docs/case-report.md: your observation, BirdNET's full ranked output, top-1 match, rank of the observed species, in-pack fraction, and an explicit list of non-local predictions
7. Run on both the indoor and outdoor myna clips

Phase 3 - ~1 hr
8. README updated with the honest framing
9. Post drafted from docs/case-report.md

Three things I still need

  1. How many clips, and do you have both indoor and outdoor separately? The indoor/outdoor contrast is the most valuable thing you have - please keep them as distinct files.

  2. Is there speech or identifiable people in the indoor audio? Hostel recordings usually do. If so we trim, or report text-only and don't publish the audio.

  3. Are you comfortable publishing the audio at all? If you'd rather not name the hostel or have voices audible, say so and I'll design around it. Better to know now than after it's pushed.

What to grab from the BirdNET app

Since you're checking it anyway, the useful comparison is app output vs our local pipeline on the same file. Not ground truth - same family of model - but if they disagree, that's worth knowing (different model version, or server-side vs local inference). Worth noting if the app returns European species for your Indian audio.

One warning: if you checked the bird ID using the BirdNET app before deciding it was Common Myna, that's not independent ground truth - it's the same model telling you what it told you. You said you saw them, so your own observation is the label. If the app is what convinced you, tell me and I'll frame it as agreement-between-models rather than a confirmed identification.

Want me to start Phase 1 now? It's independent of the audio and gets the two bugs fixed before results depend on them.

You

oh its not common myna it is yellow-billed babblerat 65.8% confidernce of Argy affinis species and well start the process budd

Agent

Yellow-billed Babbler - much better test case than Common Myna, and a good find. Let me verify it against the pack before I start.

Agent

Yellow-billed Babbler is in the pack - position 18, 19 observations, and seasonal.json already describes it as "noisy chattering groups moving through scrub, day, open scrub/roadside." That matches what you saw almost exactly.

One thing I'll hold onto for the report: the 65.8% came from the BirdNET app. Your observation is the ground truth; the app is a second voice agreeing. That's fine - but I won't write it up as the model confirming an independent identification. Not belabouring it, just noting the framing.

Starting Phase 1. First bug - the clobbered rank:

Agent
Agent
Agent

rank is numeric now (Yellow-billed Babbler = 18). taxonomicRank is undefined because my edit to the record object didn't take effect - checking:

Agent
Agent

View this session on DEV

This project was built end to end with an AI coding agent (opencode, tracked with DevRelay). The interesting part is not that it worked, but the two places where it did not:

  • The rank bug above survived several rounds because the agent optimised the thing I asked about and the symptom I noticed - ordering - happened to still be correct.
  • A ffmpeg download was corrupted twice in transit, producing a 66 MB "executable" that Windows rejected with "not a valid application for this OS platform." The PE header was valid and the machine type was correct; the file was truncated. Diagnosing that took longer than installing ffmpeg should have.

A third failure worth naming: I spent a deadline-night attempt adding a local Gemma 3 270M model to write the field brief in plain English. It ran, and every single output was invented - weather forecasts at 35 C, links to websites that do not exist, and not one mention of the bird it was supposed to describe. I deleted the feature rather than ship a birder being sent to check a website. The detection and the species filter are the project. The prose was decoration, and bad decoration is worse than none.

Prize Categories

  • Overall - this is what I am actually aiming for
  • Best Use of Entire - the project was built end to end in an agent session, the session above is embedded, and the write-up is the account of where that help stopped being useful

I considered claiming DigitalOcean and Render and deliberately did not. Deploying a deliberately-offline tool to a cloud runtime to win a hosting prize would contradict the entire premise of the project. Same reasoning for Sentry Agent Tracing - this is deterministic DSP with an ONNX model, no LLM calls, no tokens, no spans to trace.

Full write-up

The complete case study, including the control methodology and every non-local species the model proposed, is in the repo: docs/case-report.md

Top comments (0)