""" FeatherFind Perch Server (PRIMARY sound-ID engine) --------------------------------------------------- Wraps Google's Perch model, loaded DIRECTLY from TensorFlow Hub, to identify bird species from audio recordings. WHY THIS FILE IS STRUCTURED THE WAY IT IS: This server is deliberately split into two layers: 1. MODEL_LOADER / MODEL -- the ONLY place that knows which specific bioacoustics model is in use and how it's loaded. Everything below this point talks to a generic prediction interface. 2. Flask routes -- talk only to the abstraction above, never directly to the TF-Hub model object. This means swapping to a different model later should only ever require changing the MODEL_LOADER section below -- nothing in the Flask routes, response formatting, or rate-limiting logic should need to change. See backend-birdnet/server.py for the secondary engine, which intentionally mirrors this same structure. WHY DIRECT TF-HUB LOADING (not the bioacoustics-model-zoo package): We originally used the `bioacoustics-model-zoo` package's `bmz.Perch()` wrapper. It works, but it has `opensoundscape` as a hard (non-optional) dependency, which in turn pulls in a large, unrelated set of packages (Jupyter notebook server components, etc.) that we never use. On Render's free tier, this made installs slow and pushed the deploy past Render's port-detection timeout -- a real problem hit and diagnosed during this project's deployment (see ARCHITECTURE_HANDOFF.md section 3.2 for the full story). Loading directly from TensorFlow Hub avoids this entirely: only `tensorflow`, `tensorflow_hub`, and `librosa` (for audio loading/resampling) are needed. This is the same underlying Perch model, same license, same accuracy -- just a lighter path to it. MODEL CHOICE NOTE (read before changing): This is Perch v1 (`bird-vocalization-classifier/1` on TF-Hub) -- the ORIGINAL Perch model, not Perch2. Perch2 currently requires a GPU per Google's own model card. Render's free tier has no GPU. Do not swap to Perch2 here without first re-confirming a CPU-compatible build is officially available and well-tested. LICENSE NOTE: Perch is Apache 2.0 -- fully permissive, safe for commercial/app-store distribution, no restrictions. This is the main reason Perch is the PRIMARY engine, with BirdNET (CC BY-NC-SA, non-commercial) kept as a secondary/comparison engine instead. SETUP (for a parent/guardian): 1. pip install -r requirements.txt 2. python server.py 3. The first run downloads the Perch model (~80MB) and its label list -- this only happens once. 4. Deploy this to a free Python host (Render.com free tier works). """ from flask import Flask, request, jsonify from flask_cors import CORS import tempfile import os import math from datetime import date app = Flask(__name__) CORS(app) # ================================================================= # MODEL_LOADER -- the ONLY section that should change if/when we # swap to a different bioacoustics model. Everything below this # block in the rest of the file is model-agnostic. # ================================================================= print("Loading Perch model from TensorFlow Hub, this may take a moment on first run...") import numpy as np import tensorflow_hub as hub import csv import urllib.request MODEL = hub.load("https://tfhub.dev/google/bird-vocalization-classifier/1") MODEL_NAME = "Perch (Google, v1, direct TF-Hub)" # The model's label list (eBird species codes, one per output column) # is cached locally by TensorFlow Hub when the model is loaded via # hub.load() above. We resolve that same local cache path and read # label.csv directly from it -- no separate network request needed, # and no dependency on a Kaggle download URL. (An earlier version of # this code tried fetching from Kaggle directly; that URL pointed to # a tar.gz archive, not a raw CSV, and failed unpredictably across # environments -- caught and fixed during real deployment testing, # see ARCHITECTURE_HANDOFF.md section 3.2.) LABELS = [] try: model_path = hub.resolve("https://tfhub.dev/google/bird-vocalization-classifier/1") labels_path = os.path.join(model_path, "assets", "label.csv") with open(labels_path, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) LABELS = [row["ebird2021"] for row in reader if row.get("ebird2021")] print(f"Loaded {len(LABELS)} species labels.") except Exception as e: print(f"WARNING: could not load labels file ({e}). Falling back to raw index numbers as labels.") LABELS = [] print(f"{MODEL_NAME} loaded successfully.") # ================================================================= # eBird taxonomy lookup: maps bare species codes (e.g. "asikoe2") to # real common/scientific names (e.g. "Asian Koel"). Perch's own # label.csv only has bare codes -- this is a SEPARATE, independently # sourced official Cornell Lab dataset. # # Source verified directly (fetched and inspected the real file # before relying on it): the public eBird Taxonomy v2025 CSV, linked # from Cornell's own taxonomy page (science.ebird.org). Columns used: # SPECIES_CODE, PRIMARY_COM_NAME, SCI_NAME. No login or API key # needed -- this is a plain public file. # # IMPORTANT: do not trust a hand-typed code->name dictionary from any # source (including AI chat tools) without verifying against this # real file first. During development, ChatGPT/Gemini both produced # a few confident-sounding but WRONG species names for these exact # codes (e.g. claimed "busflu1" = "Bush-hen"; verified real answer is # "Buff-spotted Flufftail"). This is why we use the authoritative # file directly instead of hardcoding any name. # # PERFORMANCE NOTE: this file is bundled directly in the repo # (ebird_taxonomy.csv, alongside server.py) rather than downloaded at # every container startup. Downloading it fresh on every restart # (rebuild, sleep/wake cycle) was adding real, noticeable startup # delay -- bundling it makes startup instant for this step. If the # bundled file is ever missing (e.g. someone forgets to upload it # after cloning this repo), this code automatically falls back to # downloading it once, so the server still works either way. EBIRD_TAXONOMY_BUNDLED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ebird_taxonomy.csv") EBIRD_TAXONOMY_URL = "https://www.birds.cornell.edu/clementschecklist/wp-content/uploads/2026/04/eBird_taxonomy_v2025-4.csv" EBIRD_TAXONOMY_FALLBACK_DOWNLOAD_PATH = "/tmp/ebird_taxonomy.csv" EBIRD_CODE_TO_NAME = {} try: if os.path.exists(EBIRD_TAXONOMY_BUNDLED_PATH): taxonomy_path = EBIRD_TAXONOMY_BUNDLED_PATH print("Using bundled eBird taxonomy file (no download needed).") else: taxonomy_path = EBIRD_TAXONOMY_FALLBACK_DOWNLOAD_PATH if not os.path.exists(taxonomy_path): print("Bundled eBird taxonomy file not found -- downloading instead (one-time fallback)...") urllib.request.urlretrieve(EBIRD_TAXONOMY_URL, taxonomy_path) with open(taxonomy_path, newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: code = row.get("SPECIES_CODE") common = row.get("PRIMARY_COM_NAME") sci = row.get("SCI_NAME") if code and common: EBIRD_CODE_TO_NAME[code] = {"common": common, "scientific": sci or ""} print(f"Loaded {len(EBIRD_CODE_TO_NAME)} eBird code-to-name mappings.") except Exception as e: print(f"WARNING: could not load eBird taxonomy ({e}). Will show raw species codes instead of names.") EBIRD_CODE_TO_NAME = {} def run_model_prediction(audio_file_path): """ Model-agnostic prediction wrapper. Takes a path to an audio file, returns a list of {"commonName": str, "scientificName": str, "confidence": float 0-100} dicts, sorted by confidence descending, top 3 only. """ import librosa # Load audio, resampled to 32kHz mono, as the model requires. waveform, _ = librosa.load(audio_file_path, sr=32000, mono=True) # The model expects 5-second (160,000 sample) windows. Pad short # clips with silence; for longer clips, just use the first 5 # seconds (good enough for a hobby app; not doing multi-window # analysis here, unlike the bioacoustics-model-zoo wrapper did). target_len = 5 * 32000 if len(waveform) < target_len: waveform = np.pad(waveform, (0, target_len - len(waveform))) else: waveform = waveform[:target_len] waveform = waveform.astype(np.float32)[np.newaxis, :] logits, _embeddings = MODEL.infer_tf(waveform) logits = logits.numpy()[0] # shape: (num_classes,) # Apply sigmoid: Perch's raw outputs are uncalibrated logits, not # probabilities. Without this, confidence values can be far # outside a sane 0-100% range (this was caught and fixed during # testing -- see ARCHITECTURE_HANDOFF.md section 3.2). probabilities = 1.0 / (1.0 + np.exp(-logits)) top_indices = np.argsort(probabilities)[::-1][:3] results = [] for idx in top_indices: label = LABELS[idx] if idx < len(LABELS) else f"species_{idx}" if "_" in label: # Some label formats (other engines, or future Perch label # files) may already combine "Scientific_Common" -- handle # that format if it shows up. scientific, common = label.split("_", 1) elif label in EBIRD_CODE_TO_NAME: # The normal case for Perch: label is a bare eBird code # (e.g. "asikoe2"). Look up its real name in the eBird # taxonomy we downloaded at startup. common = EBIRD_CODE_TO_NAME[label]["common"] scientific = EBIRD_CODE_TO_NAME[label]["scientific"] else: # Taxonomy lookup failed to load, or this specific code # isn't in it (e.g. a hybrid/spuh entry) -- fall back to # showing the raw code clearly labeled, rather than # guessing at a name or showing a bare, confusing string. scientific, common = "", f"eBird code: {label}" results.append({ "commonName": common, "scientificName": scientific, "confidence": round(float(probabilities[idx]) * 100, 1), }) return results # ================================================================= # Everything below this line is model-agnostic and intentionally # mirrors backend-birdnet/server.py's structure. # ================================================================= MAX_REQUESTS_PER_DAY = 100 request_count = {"date": None, "count": 0} def check_and_increment_rate_limit(): today = str(date.today()) if request_count["date"] != today: request_count["date"] = today request_count["count"] = 0 if request_count["count"] >= MAX_REQUESTS_PER_DAY: return False request_count["count"] += 1 return True @app.route("/identify-sound", methods=["POST"]) def identify_sound(): if not check_and_increment_rate_limit(): return jsonify({ "error": "Daily identification limit reached. Please try again tomorrow.", "matches": [] }), 429 if "audio" not in request.files: return jsonify({"error": "No audio file provided.", "matches": []}), 400 audio_file = request.files["audio"] suffix = os.path.splitext(audio_file.filename or "audio.wav")[1] or ".wav" with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: audio_file.save(tmp.name) tmp_path = tmp.name try: matches = run_model_prediction(tmp_path) if not matches: return jsonify({"matches": [], "error": "No bird sound clearly detected in this recording."}) return jsonify({"matches": matches}) except Exception as e: return jsonify({"error": f"Could not analyze audio: {str(e)}", "matches": []}), 500 finally: if os.path.exists(tmp_path): os.remove(tmp_path) @app.route("/health", methods=["GET"]) def health(): return jsonify({"status": "ok", "model": MODEL_NAME}) if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) app.run(host="0.0.0.0", port=port)