<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ajaypraneeth15</title>
    <description>The latest articles on DEV Community by ajaypraneeth15 (@ajaypraneeth15).</description>
    <link>https://dev.to/ajaypraneeth15</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4160088%2F75b5a69b-dc1c-432a-b1b4-663b058fc7ed.png</url>
      <title>DEV Community: ajaypraneeth15</title>
      <link>https://dev.to/ajaypraneeth15</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ajaypraneeth15"/>
    <language>en</language>
    <item>
      <title>Building Dawn Chorus: a Local Gemma Walk Companion, Offline Birdsong ID with BirdNET, and Mock Testing</title>
      <dc:creator>ajaypraneeth15</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:49:29 +0000</pubDate>
      <link>https://dev.to/ajaypraneeth15/building-dawn-chorus-a-local-gemma-walk-companion-offline-birdsong-id-with-birdnet-and-mock-22ng</link>
      <guid>https://dev.to/ajaypraneeth15/building-dawn-chorus-a-local-gemma-walk-companion-offline-birdsong-id-with-birdnet-and-mock-22ng</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dawn Chorus&lt;/strong&gt; is an offline bird-call identifier with an unusual goal: &lt;strong&gt;the AI should make you use your phone less, not more.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You run &lt;code&gt;plan&lt;/code&gt; and get a printable bingo card of birds that are plausible at your location &lt;em&gt;this week&lt;/em&gt;. You take the paper card outside, listen, and tick squares in pencil with the phone in your pocket. Back home, &lt;code&gt;listen&lt;/code&gt; runs your voice memo through the model, writes illustrated field notes, and adds new species to a life list stored in SQLite on your own computer. Two optional features use a &lt;strong&gt;Gemma&lt;/strong&gt; model running locally in Ollama: before you go, &lt;code&gt;companion&lt;/code&gt; is a short chat that reads this week's bingo card and your life list, suggests a walk mission, and then tells you to put the phone away; and after the walk, &lt;code&gt;listen --journal&lt;/code&gt; writes a short journal entry at the top of the notes.&lt;/p&gt;

&lt;p&gt;Everything runs on your machine: no account, no API key, no upload. A trail recording can contain conversations, children's voices and clues about where you are, so it shouldn't have to leave your computer. And birding often happens where there's no signal.&lt;/p&gt;

&lt;p&gt;The part I'm proudest of is less glamorous: &lt;strong&gt;the Ollama integration is tested without Ollama.&lt;/strong&gt; More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Dawn Chorus runs on your own computer, so there is no hosted demo to click. These are screenshots from my own Windows PC, with the official two-minute BirdNET example recording (it was recorded in New York, so I used New York coordinates) and &lt;code&gt;gemma3:4b&lt;/code&gt; running locally in Ollama.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;listen --journal&lt;/code&gt;: BirdNET finds three species in 1.5 seconds, and Gemma is asked to write the journal entry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjzfkenqktbu8cuws9uqo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjzfkenqktbu8cuws9uqo.png" alt="Terminal: soundscape.wav, 120s, 40 windows, 1.5s on this machine; Black-capped Chickadee 81%, Dark-eyed Junco 74%, House Finch 64%; journal written by gemma3:4b (local Ollama)" width="620" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The field notes page it wrote.&lt;/strong&gt; The boxed paragraph at the top is Gemma's, labelled as AI-written. The list and the confidence bars below it are BirdNET's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ko5wqqtcw99fws8v3dr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ko5wqqtcw99fws8v3dr.png" alt="Field notes page with a journal paragraph written by gemma3:4b running locally in Ollama, followed by three detected species with confidence bars" width="697" height="760"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The walk companion.&lt;/strong&gt; I asked what to bring and it answered in one short sentence. (The second message is a command I pasted into the chat by accident. Gemma treated it as a message and still pointed me outside.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtscp3efx6912n9kkf6m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtscp3efx6912n9kkf6m.png" alt="Terminal chat with the Gemma companion: " width="800" height="246"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. What the printable pieces look like.&lt;/strong&gt; The bingo card is the page you print and take outside, and this is the field notes page layout. These two sample images were generated from the repository's own sample data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7p7a9va0wxs0prvxrngh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7p7a9va0wxs0prvxrngh.png" alt="Bingo card generated by Dawn Chorus" width="800" height="892"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3g64lwm739zxvhuau9u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3g64lwm739zxvhuau9u.png" alt="Field notes page generated by Dawn Chorus" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The text of the companion's opening mission and of the journal paragraph is quoted further down, in Running it on my own Windows PC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ajaypraneeth15" rel="noopener noreferrer"&gt;
        ajaypraneeth15
      &lt;/a&gt; / &lt;a href="https://github.com/ajaypraneeth15/dawn-chorus" rel="noopener noreferrer"&gt;
        dawn-chorus
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Dawn Chorus – Open Source Birdsong &amp;amp; Audio Analyzer&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;An offline bird-call identifier that exists to get you &lt;strong&gt;off the screen&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You print a bingo card of the birds likely where you are &lt;em&gt;this week&lt;/em&gt;, go stand outside with
your phone in your pocket, record a few minutes of what you hear, and check your ears against
the model when you get home. Identification, the location model, and your life list all run
on your own machine. No account, no API key, no cloud.&lt;/p&gt;
&lt;p&gt;The optional extras, a local &lt;strong&gt;Gemma&lt;/strong&gt; model that acts as your walk &lt;strong&gt;companion&lt;/strong&gt; and writes a short
field-journal entry, talk only to &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; on your own computer.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/ajaypraneeth15/dawn-chorus/samples/bingo.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fajaypraneeth15%2Fdawn-chorus%2FHEAD%2Fsamples%2Fbingo.png" alt="A generated bingo card"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Contents&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#overview-and-the-problem-it-solves" rel="noopener noreferrer"&gt;Overview and the problem it solves&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#tech-stack" rel="noopener noreferrer"&gt;Tech stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#windows-quick-start-no-terminal-knowledge-needed" rel="noopener noreferrer"&gt;Windows quick start&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#setup" rel="noopener noreferrer"&gt;Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#running-it" rel="noopener noreferrer"&gt;Running it&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#optional-a-local-gemma-writes-your-field-journal" rel="noopener noreferrer"&gt;Optional: a local Gemma writes your field journal&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#testing" rel="noopener noreferrer"&gt;Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#directory-structure" rel="noopener noreferrer"&gt;Directory structure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#honest-limits" rel="noopener noreferrer"&gt;Honest limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajaypraneeth15/dawn-chorus#license" rel="noopener noreferrer"&gt;License&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Overview and the problem it solves&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Most AI…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ajaypraneeth15/dawn-chorus" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;I built this in one session with Claude, Anthropic's AI coding agent, so "we" below means the two of us. I gave Claude the challenge brief; Claude wrote the code and ran every test described here in its own cloud sandbox. Claude's sandbox could not reach Ollama, so Claude tested the Gemma code against stand-ins (see Testing). I then ran &lt;code&gt;doctor&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;listen&lt;/code&gt;, &lt;code&gt;companion&lt;/code&gt; and &lt;code&gt;listen --journal&lt;/code&gt; on my own Windows PC, with a real &lt;code&gt;gemma3:4b&lt;/code&gt; through Ollama (see Running it on my own Windows PC).&lt;/p&gt;

&lt;p&gt;Claude is only the builder: nothing in Dawn Chorus calls Claude or any hosted API when it runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who does what
&lt;/h3&gt;

&lt;p&gt;It helps to be precise about which model does which job, because "AI" covers several different things here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Identify birds in audio&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;BirdNET V2.4&lt;/strong&gt; (open-weight classifier)&lt;/td&gt;
&lt;td&gt;Your CPU, via Google's LiteRT/TFLite runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decide which birds are plausible here and now&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;BirdNET location model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat before the walk (&lt;code&gt;companion&lt;/code&gt;) and write a short journal paragraph (&lt;code&gt;--journal&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gemma&lt;/strong&gt; (optional)&lt;/td&gt;
&lt;td&gt;Your computer, served by Ollama&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemma never hears the audio. It only sees short text lists: the bingo card, or the species BirdNET already found. So the bird &lt;em&gt;identifying&lt;/em&gt; is BirdNET's job, and Gemma's is the talking and writing.&lt;/p&gt;

&lt;p&gt;The stack is Python, numpy, &lt;code&gt;ai-edge-litert&lt;/code&gt;, ffmpeg, SQLite, and the standard library's &lt;code&gt;urllib&lt;/code&gt; for talking to Ollama. I did not use Librosa or &lt;code&gt;requests&lt;/code&gt;: ffmpeg decodes the audio to 48 kHz mono, and &lt;code&gt;urllib&lt;/code&gt; is enough for three small HTTP calls. That keeps the install to three pip packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Dawn Chorus
                         |
             +-----------+-----------+
             |                       |
        Audio recording        Location + week
        (ffmpeg decode)                |
             |                       v
             v              BirdNET location model
      BirdNET audio model     "which species are
      (3 s windows)            plausible here?"
             |                       |
             +-----------+-----------+
                         |
                         v
              Detections (local only)
                         |
          +--------------+--------------+
          |              |              |
     Field notes     Life list      (optional)
     (HTML)          (SQLite)       Gemma journal
                                    via local Ollama

  Before the walk:  bingo card + life list --&amp;gt; Gemma companion (local Ollama chat)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Listening.&lt;/strong&gt; Audio is split into three-second windows, each run through the BirdNET classifier. We keep detections above a confidence threshold (default 0.5) that the location model also considers plausible for the place and week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Location awareness, twice.&lt;/strong&gt; The location model filters listening results, and it also builds the bingo card. On the official BirdNET example recording, low-confidence guesses like Hawfinch and Chestnut-winged Cuckoo disappear under a New York location.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real bug, found because the model is open.&lt;/strong&gt; BirdNET has seven non-bird labels: Engine, Dog, Siren, Gun, Fireworks, Noise and Environmental. The first test run reported "Engine" as a new species for the life list. Because the label file and raw outputs are accessible, we masked those classes out and added a test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline for real.&lt;/strong&gt; &lt;code&gt;scripts/offline_check.sh&lt;/code&gt; runs the whole pipeline inside a Linux network namespace whose only interface is loopback, so anything that tried to phone home would fail. Planning and identification both pass there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Ollama integration: &lt;code&gt;ollama_journal&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The function talks to Ollama's REST API on &lt;code&gt;http://127.0.0.1:11434&lt;/code&gt; using only the standard library. Here is the heart of it, trimmed for the article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ollama_journal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OLLAMA_DEFAULT_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Never route traffic for this same computer through an HTTP proxy.
&lt;/span&gt;    &lt;span class="n"&gt;opener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_opener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ProxyHandler&lt;/span&gt;&lt;span class="p"&gt;({}))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_is_local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_opener&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;models&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemma&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# prefer Gemma if installed
&lt;/span&gt;    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;journal_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_predict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma first, with a graceful fallback.&lt;/strong&gt; With no &lt;code&gt;--ollama-model&lt;/code&gt;, the tool lists installed models and picks the first one with &lt;code&gt;gemma&lt;/code&gt; in its name (case-insensitive). If there is none, it uses the first model Ollama lists. An explicit &lt;code&gt;--ollama-model&lt;/code&gt; always wins and skips the lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fenced-in prompt.&lt;/strong&gt; The prompt contains only the species list, place and date, and says to use only those facts and not to invent behaviour. A small model can still be wrong, so the page labels the paragraph as AI-written and the detection list stays the record. The model's text is HTML-escaped before it reaches the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fails soft.&lt;/strong&gt; If Ollama isn't running, the model isn't pulled, the request times out, or the answer is empty or garbled, the tool raises a friendly &lt;code&gt;OllamaError&lt;/code&gt;, prints &lt;code&gt;journal skipped: ...&lt;/code&gt; and still writes the notes. An unknown model gets an &lt;code&gt;ollama pull &amp;lt;model&amp;gt;&lt;/code&gt; hint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local by default.&lt;/strong&gt; If you point &lt;code&gt;--ollama-host&lt;/code&gt; at another machine, the tool prints a notice that your detections will be sent there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Gemma companion
&lt;/h3&gt;

&lt;p&gt;The most important design question was how to use a chat model in an app whose whole point is to get you &lt;em&gt;away&lt;/em&gt; from the screen. A chatbot that keeps you talking would defeat it. So &lt;code&gt;companion&lt;/code&gt; is built to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 dawn_chorus.py companion &lt;span class="nt"&gt;--lat&lt;/span&gt; 42.45 &lt;span class="nt"&gt;--lon&lt;/span&gt; &lt;span class="nt"&gt;-76&lt;/span&gt;.50 &lt;span class="nt"&gt;--place&lt;/span&gt; &lt;span class="s2"&gt;"Cayuga Lake trail"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It starts with a mission, not a greeting.&lt;/strong&gt; The first message is generated for you: a short walk mission built from this week's bingo card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is short on purpose.&lt;/strong&gt; Replies are limited to about three sentences, the number of turns is capped (default 4, maximum 10), and an empty line or &lt;code&gt;go&lt;/code&gt; ends the chat. The &lt;em&gt;program&lt;/em&gt;, not the model, prints the closing line: "Time to go outside. Phone in your pocket..." and the command to run when you are back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is grounded in facts we really have.&lt;/strong&gt; The system prompt holds today's date and week, your place, the 24 birds on the card with how likely each is this week (from the BirdNET location model), and your life-list size and latest additions. The model is told it may mention &lt;em&gt;only&lt;/em&gt; those birds and must not describe their songs, looks or habitat, because a small model can be confidently wrong about birds. General tips, like standing still and listening for five quiet minutes, are fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fails soft.&lt;/strong&gt; If Ollama isn't running you get a friendly message that points to &lt;code&gt;plan&lt;/code&gt;, which needs no language model. If the connection drops mid-chat, the chat ends politely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language.&lt;/strong&gt; &lt;code&gt;--language Tamil&lt;/code&gt; (or any language name) is added to the instructions. I speak Tamil, and Gemma lists many languages, but I have not tested how good a small model is in Tamil.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The conversation loop is a small pure function, which is what makes it testable without a model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;companion_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;say&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MISSION_REQUEST&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you&amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;END_WORDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# "", "go", "bye", "quit", ...
&lt;/span&gt;            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                             &lt;span class="c1"&gt;# POST /api/chat with the whole history
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Testing without Ollama
&lt;/h3&gt;

&lt;p&gt;A test suite that needs a running LLM server, a multi-gigabyte download and a GPU is a test suite nobody runs. So the Ollama code is tested three ways, and none of them needs Ollama:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A stand-in server (26 tests in &lt;code&gt;tests/test_dawn_chorus.py&lt;/code&gt;, including Windows-portability checks).&lt;/strong&gt; A tiny &lt;code&gt;FakeOllama&lt;/code&gt; HTTP server starts on a free local port and speaks the real protocol (&lt;code&gt;/api/tags&lt;/code&gt;, &lt;code&gt;/api/generate&lt;/code&gt;). This exercises the actual &lt;code&gt;urllib&lt;/code&gt; code, headers and JSON parsing end to end, alongside the BirdNET geography tests and a real-audio run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;unittest.mock&lt;/code&gt; (24 tests in &lt;code&gt;tests/test_ollama_mock.py&lt;/code&gt;).&lt;/strong&gt; Here the HTTP layer is replaced by a mock opener, and a guard patches &lt;code&gt;socket.socket.connect&lt;/code&gt; so that any accidental real network call fails the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NoRealNetwork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;socket.socket.connect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;side_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;real network call attempted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of that, tests cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the request is a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/api/generate&lt;/code&gt; with the chosen model, &lt;code&gt;stream: false&lt;/code&gt; and a prompt listing the detected birds;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma priority:&lt;/strong&gt; Gemma beats models listed before it, matching ignores case, the first Gemma wins when several are installed, no Gemma falls back to the first model, and an explicit model skips the lookup;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;failure modes:&lt;/strong&gt; connection refused, timeout, 404 (with the pull hint), 500 (without it), no models, empty answer, missing &lt;code&gt;response&lt;/code&gt; field, garbled JSON;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;staying local:&lt;/strong&gt; loopback bypasses system proxies, remote hosts are flagged, and &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;librosa&lt;/code&gt; and &lt;code&gt;urllib.request&lt;/code&gt; aren't even imported until &lt;code&gt;--journal&lt;/code&gt; is used;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;prompt safety:&lt;/strong&gt; only detected birds appear in the prompt, and &lt;code&gt;&amp;lt;img src=x onerror=...&amp;gt;&lt;/code&gt; in model output is escaped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. The companion (22 tests in &lt;code&gt;tests/test_companion.py&lt;/code&gt;).&lt;/strong&gt; The &lt;code&gt;/api/chat&lt;/code&gt; client is tested with the same mock opener. The conversation loop is tested with scripted inputs: the opening mission, the whole history reaching the model, the turn cap holding even if the person keeps typing, quitting on an empty line or &lt;code&gt;bye&lt;/code&gt;, Ctrl+C and end-of-input, and Ollama disappearing mid-chat (the failed turn is removed from the history). The system prompt is tested for what it must contain (all 24 card birds, the likelihood words, the rules) and what it must not (birds that aren't on the card). Finally, the &lt;code&gt;companion&lt;/code&gt; command runs end to end against the stand-in server.&lt;/p&gt;

&lt;p&gt;I also mutation-checked the suite: breaking the Gemma preference, the fallback model, &lt;code&gt;stream: false&lt;/code&gt;, the turn cap, the endpoint, or the card contents each makes tests fail, which is how you know the assertions aren't decorative.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI/CD
&lt;/h3&gt;

&lt;p&gt;Because nothing needs a network or a model server, the whole suite (72 tests, about 12 seconds on Claude's sandbox, including real-audio inference) can run on a plain CI runner. Here is a GitHub Actions workflow you could drop into &lt;code&gt;.github/workflows/test.yml&lt;/code&gt;. &lt;strong&gt;It's a suggestion: I haven't run it on GitHub Actions.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.12"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sudo apt-get install -y ffmpeg&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install numpy ai-edge-litert &amp;amp;&amp;amp; pip install --no-deps birdnetlib&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python -W error::ResourceWarning -m unittest discover -s tests -v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Testing, honestly
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tested, by Claude in its sandbox:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;72 automated tests pass (26 with the stand-in server, 24 with &lt;code&gt;unittest.mock&lt;/code&gt;, 22 for the companion).&lt;/li&gt;
&lt;li&gt;End-to-end identification on the official two-minute BirdNET example soundscape, in about 1.5 seconds on a 2-vCPU machine with no GPU. That's one machine, so I'm not claiming the same speed everywhere.&lt;/li&gt;
&lt;li&gt;The full pipeline with the network disabled.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/check_ollama.sh&lt;/code&gt; against a fake Ollama server in five situations (Gemma installed, no Gemma, no models, a &lt;code&gt;-cloud&lt;/code&gt; model, server down), with the expected exit code each time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not tested yet:&lt;/strong&gt; a phone recording from a real walk, Raspberry Pi, macOS, the companion in Tamil, and the repository's &lt;code&gt;.bat&lt;/code&gt; helper files (I ran the commands by hand on Windows instead). Claude's sandbox never ran a real Gemma model; the real runs below are mine, on my own PC. The mock and stand-in tests prove the client code and the conversation logic are right; they say nothing about how helpful or accurate a Gemma model is, which is why I ran a real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real Gemma runs (mine, on my own PC, &lt;code&gt;gemma3:4b&lt;/code&gt; through Ollama):&lt;/strong&gt; the sections below show exactly what came out, including where it slipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running it on my own Windows PC
&lt;/h3&gt;

&lt;p&gt;The code was written and tested on Linux, so I ran it on my Windows PC (Python 3.14) to find out what happens. BirdNET runs on the CPU there (TensorFlow Lite's XNNPACK delegate). Results, from my own screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;python dawn_chorus.py doctor&lt;/code&gt; loads both BirdNET models (6,522 species), in &lt;strong&gt;0.30 s&lt;/strong&gt; on one run and &lt;strong&gt;1.65 s&lt;/strong&gt; on a later one (timings vary from run to run), with one audio window plus a location lookup taking &lt;strong&gt;42 to 58 ms&lt;/strong&gt;, and reports no network modules imported.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python dawn_chorus.py plan&lt;/code&gt; generated a bingo card: "24 targets, 75 species plausible at 42.45,-76.50 in week 37/48". For my own town, Batticaloa in Sri Lanka (&lt;code&gt;--lat 7.71 --lon 81.69&lt;/code&gt;), it gave "24 targets, 222 species plausible ... in week 37/48".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python dawn_chorus.py listen&lt;/code&gt; on the official two-minute BirdNET example recording (it was recorded in New York, so I used New York coordinates) took &lt;strong&gt;1.9 s&lt;/strong&gt; and then &lt;strong&gt;1.5 s&lt;/strong&gt; on a second run, for 40 three-second windows. It found three species: Black-capped Chickadee (81%), Dark-eyed Junco (74%) and House Finch (64%). I did not check these by ear; they are BirdNET's detections.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ffmpeg&lt;/code&gt; is needed to decode audio. &lt;code&gt;winget install Gyan.FFmpeg&lt;/code&gt; was a 245 MB download, and I had to restart my shell before the command was found.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two Windows-only problems came up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The AI runtime would not load.&lt;/strong&gt; &lt;code&gt;ai-edge-litert&lt;/code&gt; installed fine, but importing it failed with &lt;code&gt;DLL load failed ... The specified module could not be found&lt;/code&gt;. I opened the package's Windows files and its DLLs import &lt;code&gt;MSVCP140.dll&lt;/code&gt; and &lt;code&gt;VCRUNTIME140_1.dll&lt;/code&gt;, which come from the Microsoft Visual C++ Redistributable. Installing it (&lt;code&gt;winget install Microsoft.VCRedist.2015+.x64&lt;/code&gt;) fixed it. The tool now prints the real import error instead of a bare "install ai-edge-litert", and the Windows setup helper explains this fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A date format Windows rejects.&lt;/strong&gt; Reading the code for Windows problems showed &lt;code&gt;strftime('%-d')&lt;/code&gt; in three places. Windows raises &lt;code&gt;ValueError&lt;/code&gt; for it, which would have crashed &lt;code&gt;plan&lt;/code&gt; and &lt;code&gt;listen&lt;/code&gt;. It is replaced by a small helper, and tests simulate the Windows behaviour on any platform.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  The companion, with a real Gemma
&lt;/h4&gt;

&lt;p&gt;I pulled &lt;code&gt;gemma3:4b&lt;/code&gt; (3.3 GB) and ran &lt;code&gt;companion --lat 7.71 --lon 81.69 --place "Batticaloa"&lt;/code&gt;. Its opening mission was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Let's see if we can spot a White-throated Kingfisher today – they're looking good this week. Head out and see if you can find one near the water!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Checking it against the card: the White-throated Kingfisher really is on my Batticaloa card, marked "likely", so "looking good this week" matches the data. But "near the water" is a habitat remark, which the prompt tells the model not to make. It is a small slip, and a good example of why I kept the instructions strict and the replies short. I then asked "what should I bring?" and it said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Just a bottle of water and a notebook to jot down any sightings – that's all you need for a pleasant walk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a safe, general tip, which the prompt allows. By accident I also pasted a shell command into the chat. Gemma treated it as a message and still steered me outside ("Find a quiet spot and just listen for a few minutes"). Pressing Enter on an empty line ended the session, and the program printed the closing "Time to go outside" lines and &lt;code&gt;(chatted with gemma3:4b, running on this computer)&lt;/code&gt;. I did not measure how long each reply took.&lt;/p&gt;

&lt;h4&gt;
  
  
  The journal, with a real Gemma
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;listen examples\soundscape.wav --lat 42.45 --lon -76.50 --journal --ollama-model gemma3:4b&lt;/code&gt; wrote this into the field notes page (the program labels it "written by gemma3:4b, running locally in Ollama" and "AI-written from the detections below. It can be wrong; the list is the record"):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The automatic detections picked up a Black-capped Chickadee early in the recording, a brief but clear signal. Later, a House Finch was identified in several short segments, though the confidence level was lower. Finally, a Dark-eyed Junco was detected twice, offering a slightly more reliable identification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I checked it against the list below it. The Chickadee was heard once at 0:00 (81%), the House Finch three times (first at 0:09, 64%) and the Junco twice (74%), so the counts and the "lower" and "more reliable" comparisons are right, and it names no bird that BirdNET did not detect. It is flat rather than lovely, and "clear signal" is the model's own embellishment. I would not call that great writing, but it is faithful, which is the thing a small local model can promise here. This is one run, not an evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ajaypraneeth15/dawn-chorus.git
&lt;span class="nb"&gt;cd &lt;/span&gt;dawn-chorus
pip &lt;span class="nb"&gt;install &lt;/span&gt;numpy ai-edge-litert
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; birdnetlib          &lt;span class="c"&gt;# carries the BirdNET weights (~66 MB)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg                   &lt;span class="c"&gt;# or: brew install ffmpeg&lt;/span&gt;

python3 &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; tests &lt;span class="nt"&gt;-v&lt;/span&gt;  &lt;span class="c"&gt;# no Ollama needed&lt;/span&gt;
sh scripts/get_example_audio.sh           &lt;span class="c"&gt;# fetch the example recording&lt;/span&gt;
python3 dawn_chorus.py plan &lt;span class="nt"&gt;--lat&lt;/span&gt; 42.45 &lt;span class="nt"&gt;--lon&lt;/span&gt; &lt;span class="nt"&gt;-76&lt;/span&gt;.50 &lt;span class="nt"&gt;--place&lt;/span&gt; &lt;span class="s2"&gt;"Cayuga Lake trail"&lt;/span&gt;
python3 dawn_chorus.py listen examples/soundscape.wav &lt;span class="nt"&gt;--lat&lt;/span&gt; 42.45 &lt;span class="nt"&gt;--lon&lt;/span&gt; &lt;span class="nt"&gt;-76&lt;/span&gt;.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add Gemma (the model download is a few gigabytes, so mind your data; my &lt;code&gt;gemma3:4b&lt;/code&gt; was 3.3 GB):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama serve                    &lt;span class="c"&gt;# the desktop app does this for you&lt;/span&gt;
ollama pull gemma3:4b           &lt;span class="c"&gt;# what I used; gemma3:1b is smaller but I have not tried it. Tag names change; check https://ollama.com/library&lt;/span&gt;
bash scripts/check_ollama.sh    &lt;span class="c"&gt;# is Ollama up, and is a Gemma model installed?&lt;/span&gt;
python3 dawn_chorus.py companion &lt;span class="nt"&gt;--lat&lt;/span&gt; 42.45 &lt;span class="nt"&gt;--lon&lt;/span&gt; &lt;span class="nt"&gt;-76&lt;/span&gt;.50                       &lt;span class="c"&gt;# chat before the walk&lt;/span&gt;
python3 dawn_chorus.py listen examples/soundscape.wav &lt;span class="nt"&gt;--lat&lt;/span&gt; 42.45 &lt;span class="nt"&gt;--lon&lt;/span&gt; &lt;span class="nt"&gt;-76&lt;/span&gt;.50 &lt;span class="nt"&gt;--journal&lt;/span&gt;   &lt;span class="c"&gt;# journal after&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, use &lt;code&gt;python&lt;/code&gt; instead of &lt;code&gt;python3&lt;/code&gt;, install the Visual C++ Redistributable if the AI runtime won't load, and the repository's &lt;code&gt;windows&lt;/code&gt; folder has double-click &lt;code&gt;.bat&lt;/code&gt; helpers (setup, check, plan, demo, Gemma, tests, companion). Those helpers have not been run by anyone yet.&lt;/p&gt;

&lt;p&gt;Model names on Ollama change over time, so use &lt;code&gt;ollama list&lt;/code&gt; to see what you have. A model whose name ends in &lt;code&gt;-cloud&lt;/code&gt; runs on Ollama's servers, not on your computer, so pick one without that suffix for a fully local run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It works without the internet.&lt;/strong&gt; A cloud-only identifier fails exactly where birders go. Here the models run locally, and offline operation was tested, not just promised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recordings stay on your machine.&lt;/strong&gt; With local inference there's no need to upload anything. The notes and life list stay local too, and the optional Gemma step talks only to Ollama on &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We could inspect and fix the model's behaviour.&lt;/strong&gt; A closed bird-ID API might simply return "Engine" as a bird. With open files we found the seven non-bird classes, masked them, and wrote a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We could reuse one model for a different purpose.&lt;/strong&gt; The location model that filters results also builds a physical bingo card before you leave home. One open capability now covers planning, going outside and identifying, and the app gets out of the way during the walk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-call cost.&lt;/strong&gt; No API keys, quotas or inference charges, which makes experimenting easy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Light enough for an ordinary PC.&lt;/strong&gt; On my Windows PC, loading both BirdNET models took between 0.3 and 1.7 s and a window plus location lookup 42 to 58 ms across two runs. I have not measured energy use, so I am not making claims about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A note on licensing
&lt;/h3&gt;

&lt;p&gt;Dawn Chorus's code is &lt;strong&gt;MIT&lt;/strong&gt;. The BirdNET weights are &lt;strong&gt;CC BY-NC-SA 4.0&lt;/strong&gt;: free to use and modify, but &lt;strong&gt;not for commercial use&lt;/strong&gt;, which is why I call BirdNET &lt;em&gt;open-weight&lt;/em&gt; instead of claiming it shares my code's license. Gemma is used under Google's Gemma terms and is downloaded by you through Ollama.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;BirdNET is by Kahl, Wood, Eibl &amp;amp; Klinck (Cornell Lab of Ornithology and Chemnitz University of Technology), Ecological Informatics 61, 101236 (2021).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built this project myself, with minimal assistance from Claude. I used Claude mainly for guidance and support in areas of the process that I wasn’t familiar with, from reviewing the challenge brief to testing, packaging, and writing this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The first job is a real walk: record a phone soundscape, run it through &lt;code&gt;listen&lt;/code&gt;, and compare against what I can identify myself. After that: a systematic check of how helpful a real Gemma companion is over many runs (including in Tamil, which I have not tried), macOS and Raspberry Pi testing, and fine-tuned BirdNET models for local birds.&lt;/p&gt;

&lt;p&gt;The idea to keep is simple: &lt;strong&gt;use AI to make the screen less important.&lt;/strong&gt; Print the card. Put the phone away. Go outside. 🐦🌿&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemma.&lt;/strong&gt; Dawn Chorus uses Gemma, Google's open-weight model, running locally through Ollama, as a short outdoor companion before the walk (&lt;code&gt;companion&lt;/code&gt;, using &lt;code&gt;POST /api/chat&lt;/code&gt;) and to write the optional journal entry in the field notes (&lt;code&gt;listen --journal&lt;/code&gt;). The integration is in &lt;code&gt;dawn_chorus.py&lt;/code&gt; (&lt;code&gt;ollama_chat&lt;/code&gt;, &lt;code&gt;companion_session&lt;/code&gt;, &lt;code&gt;ollama_journal&lt;/code&gt;), with dozens of tests and a preflight script, and it is documented in the README. I ran both features myself with a real &lt;code&gt;gemma3:4b&lt;/code&gt; on my own computer, and the output, including a small rule slip, is shown above.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>ollama</category>
    </item>
  </channel>
</rss>
