Bioacoustics Toolkit
Spectrograms, BirdNET, PNW-Cnet, and how to train a small spectrogram classifier of your own.
1. From sound to spectrogram
Almost every modern acoustic classifier (BirdNET, PNW-Cnet, Perch, BirdNET-Analyzer) treats audio as an image. The image is a spectrogram: time on the x-axis, frequency on the y-axis, and color encoding amplitude. The conversion uses the Short-Time Fourier Transform (STFT), which slides a window across the waveform and asks "what frequencies are present right now?".
import librosa, numpy as np
import matplotlib.pyplot as plt
y, sr = librosa.load("ARU_2023-06-14_dawn.wav", sr=22050, mono=True, duration=10)
# Mel-spectrogram: STFT, then warp the frequency axis to the mel scale
# (roughly logarithmic, matching how mammals hear pitch).
S = librosa.feature.melspectrogram(y=y, sr=sr, n_fft=2048,
hop_length=512, n_mels=128, fmax=11000)
S_db = librosa.power_to_db(S, ref=np.max)
fig, ax = plt.subplots(figsize=(8, 3))
librosa.display.specshow(S_db, x_axis="time", y_axis="mel", sr=sr,
fmax=11000, ax=ax)
ax.set_title("Mel-spectrogram of 10 s of dawn chorus")
plt.colorbar(ax.collections[0], ax=ax, format="%+2.0f dB")
plt.tight_layout()
plt.savefig("melspec.png", dpi=150)
Knobs that matter for ecology:
- Sample rate (
sr). 22.05 kHz catches everything up to ~11 kHz, which is enough for most birds and mammals. For bats, you need ultrasonic recordings (250+ kHz) and a different toolkit. - Window length (
n_fft). Long windows give fine frequency resolution but blur time. 2048 samples (~93 ms at 22.05 kHz) is a good default. - Hop length. How far the window steps between frames. Smaller = smoother spectrogram but more compute.
2. BirdNET
BirdNET (Kahl et al., Cornell Lab of Ornithology) is a 6,000+-class bird vocalization classifier. It takes 3-second mel-spectrograms and outputs per-class scores. It's the most widely used tool for passive bird monitoring in 2026.
Installing BirdNET-Analyzer
pip install birdnet-analyzer
# or, the CLI tool from the official repo:
git clone https://github.com/kahst/BirdNET-Analyzer
cd BirdNET-Analyzer
pip install -r requirements.txt
Running BirdNET from Python
"""
run_birdnet.py
Run BirdNET on every WAV in a directory and write a tidy detections CSV.
"""
import argparse, csv
from pathlib import Path
from birdnet_analyzer.analyze import analyze
# birdnet_analyzer wraps the Cornell models; the call signature changes
# occasionally — if this breaks, run `birdnet-analyzer --help` and adjust.
def main():
p = argparse.ArgumentParser()
p.add_argument("--audio", required=True, help="Directory of WAV files")
p.add_argument("--out", required=True, help="Output CSV")
p.add_argument("--lat", type=float, default=44.06, help="Site latitude")
p.add_argument("--lon", type=float, default=-121.32, help="Site longitude")
p.add_argument("--week", type=int, default=24, help="Week of year")
p.add_argument("--min_conf", type=float, default=0.5)
args = p.parse_args()
rows = []
for wav in sorted(Path(args.audio).glob("*.wav")):
# 'analyze' returns a list of dicts: start_s, end_s, scientific_name,
# common_name, confidence. The lat/lon/week tell BirdNET which species
# to prioritise via its eBird-derived prior.
detections = analyze(
wav, lat=args.lat, lon=args.lon, week=args.week,
min_conf=args.min_conf, overlap=0.0, sensitivity=1.0,
)
for d in detections:
rows.append({"file": wav.name, **d})
with open(args.out, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
print(f"Wrote {len(rows)} detections to {args.out}")
if __name__ == "__main__":
main()
Interpreting BirdNET outputs
- Lower-frequency species (owls, grouse) tend to have systematically lower scores. A 0.3 barred-owl detection is often correct; a 0.3 yellow-warbler detection often isn't.
- BirdNET's eBird prior means that providing the right latitude/longitude/week meaningfully reduces false positives. Always set them.
- BirdNET is trained for bird vocalizations. It will sometimes "detect" frogs, squirrels, or mechanical noise as a bird. Hand-check.
3. PNW-Cnet
PNW-Cnet has two front-ends:
- pycnet-audio — the Python / CLI front-end. Scriptable, reproducible, ideal for batch processing on a server. This is what we'll set up below.
- Shiny PNW-Cnet — an R Shiny desktop GUI that wraps the same model. Same inputs and outputs, but with sliders, file pickers, and a built-in review interface. The right choice for field crews who don't want a terminal — especially undergrads and partner-agency biologists. Lab 11 walks you through using it; for the rest of this tutorial we'll stick with the CLI.
3a. PNW-Cnet via pycnet-audio
PNW-Cnet (Pacific Northwest Convolutional Neural Network) is a CNN developed at Oregon State University by Zack Ruff and colleagues to monitor northern spotted owls, barred owls, marbled murrelets, and dozens of other species of forest-management concern. Unlike BirdNET, it works on non-overlapping 12-second spectrograms covering 0–4000 Hz, and it is explicitly calibrated for Pacific NW soundscapes. The current model (v5) has 135 output classes; the older v4 model has 51.
Installation
pycnet-audio requires SoX (a command-line audio tool) for
spectrogram generation. It also pins to a TensorFlow version that fights with
birdnet, so it gets its own conda env (one of the
canonical cases for environments — see the
Python Environments tutorial for the why):
mamba create -n pycnet -c conda-forge sox python=3.11 -y
conda activate pycnet
pip install pycnet-audio
# Sanity check
pycnet --help
pycnet-audio supports Python 3.8–3.11. If your system Python is 3.12+, the
conda env above is mandatory; pip install into a 3.12 env will fail at the
TensorFlow dependency.
Running PNW-Cnet end-to-end on a folder of WAVs
The all-in-one command is pycnet process. It inventories audio, generates
spectrograms, scores them with the model, and writes both a class-scores CSV and a
detection-summary CSV:
# macOS / Linux
pycnet process /data/aru/site_blue_river \
-c v5 \
-r "STOC_4Note 0.50 STVA_8Note 0.75" \
-w 4 \
-a
# Windows (PowerShell or Git Bash) — same command, Windows-style path:
pycnet process F:\aru\site_blue_river -c v5 -r "STOC_4Note 0.50" -a
Common flags:
| Flag | Meaning |
|---|---|
-c v5 | Model version (v4 = 51 classes,
v5 = 135 classes; default v5). |
-r "CLASS thr CLASS thr ..." |
Per-class score thresholds for the review file. |
-w N | Worker processes for parallel spectrogram generation. |
-a | Auto-delete the temporary spectrogram folder when done. |
-f | Process FLAC files instead of WAV. |
-i <path> | Write spectrograms to a different drive (useful when your audio is on a slow archive disk). |
Other subcommands you'll meet:
pycnet spectro <dir>— generate spectrograms only.pycnet predict <dir>— score existing spectrograms.pycnet review <dir> -r ...— re-build the review file at new thresholds without re-running the model.pycnet batch_process file_of_dirs.txt— runprocessacross many sites listed in a text file.
Output files
pycnet process writes three CSVs into the audio directory:
<site>_v5_class_scores.csv— one row per 12-second spectrogram:Filename+ 135 class-score columns (or 51 for v4), each a [0,1] score.<site>_v5_detection_summary.csv— aggregated detection counts by Area / Site / Stn / Date / Threshold, plusClips(number of 12-s segments) andEffort(recording hours = Clips / 300).<site>_review_kscope.csv— Kaleidoscope-format review file: one row per above-threshold clip, withFOLDER, IN_FILE, OFFSET, DURATION, DATE, TIME, TOP1MATCH, TOP1DIST, AUTO_TAG, MANUAL_IDfor human review.
Converting class-scores to long-form detections in Python
import pandas as pd
raw = pd.read_csv("site_blue_river_v5_class_scores.csv")
# Stack the class-score columns into long form
long = raw.melt(id_vars=["Filename"], var_name="class", value_name="score")
# Keep only above-threshold detections
detections = long[long["score"] >= 0.5].sort_values(["Filename", "class"])
detections.to_csv("site_blue_river_detections.csv", index=False)
print(detections["class"].value_counts().head(10))
When to use PNW-Cnet vs BirdNET
| BirdNET | PNW-Cnet (v5) | |
|---|---|---|
| Geographic coverage | Global | Pacific Northwest only |
| Class count | 6,000+ | 135 (focused) |
| Time window | 3 s | 12 s, non-overlapping |
| Frequency range | up to ~15 kHz | 0–4000 Hz |
| Best for | Songbird species lists | Owls, marbled murrelet, forest management indicator species |
| Calibration burden | High — many classes, generic priors | Lower — calibrated for PNW soundscapes |
| GUI front-end | BirdNET-Analyzer (cross-platform) | Shiny_PNW-Cnet (R Shiny app) |
4. Training your own spectrogram classifier
Sometimes the species or call type you care about isn't in BirdNET or PNW-Cnet. The standard pattern: cut your audio into 3–5 s clips, compute mel-spectrograms, and fine-tune a small CNN (e.g., MobileNetV3 or ResNet-18) on the spectrograms as images.
"""
train_spec_cnn.py
Train a small CNN on a folder of labeled audio clips.
Expected layout:
clips/
barred_owl_duet/
clip0001.wav
clip0002.wav
...
background/
...
other_owl/
...
"""
import torch, torch.nn as nn, torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torchvision import models, transforms
import librosa, numpy as np
from pathlib import Path
from PIL import Image
import io
class SpectrogramDataset(Dataset):
"""Compute the mel-spectrogram on the fly for each clip."""
def __init__(self, root, sr=22050, n_mels=128, duration=3.0, transform=None):
self.items, self.classes = [], sorted(p.name for p in Path(root).iterdir() if p.is_dir())
for label, cls in enumerate(self.classes):
for wav in (Path(root) / cls).glob("*.wav"):
self.items.append((wav, label))
self.sr, self.n_mels, self.duration = sr, n_mels, duration
self.transform = transform
def __len__(self):
return len(self.items)
def __getitem__(self, idx):
wav, label = self.items[idx]
y, _ = librosa.load(wav, sr=self.sr, mono=True, duration=self.duration)
# Zero-pad short clips
target = int(self.sr * self.duration)
if len(y) < target:
y = np.pad(y, (0, target - len(y)))
S = librosa.feature.melspectrogram(y=y, sr=self.sr, n_mels=self.n_mels)
S_db = librosa.power_to_db(S, ref=np.max)
# Normalize to [0, 1] and stack to 3 channels so we can use ImageNet weights
img = (S_db - S_db.min()) / (S_db.max() - S_db.min() + 1e-9)
img = np.stack([img] * 3, axis=0).astype(np.float32)
x = torch.from_numpy(img)
if self.transform:
x = self.transform(x)
return x, label
def main():
device = "cuda" if torch.cuda.is_available() else "cpu"
train_ds = SpectrogramDataset("clips/train")
val_ds = SpectrogramDataset("clips/val")
train_dl = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=2)
val_dl = DataLoader(val_ds, batch_size=16, shuffle=False, num_workers=2)
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(model.fc.in_features, len(train_ds.classes))
model = model.to(device)
crit = nn.CrossEntropyLoss()
opt = optim.AdamW(model.parameters(), lr=3e-4)
for epoch in range(8):
model.train()
for x, y in train_dl:
x, y = x.to(device), y.to(device)
opt.zero_grad()
loss = crit(model(x), y)
loss.backward()
opt.step()
model.eval()
correct = total = 0
with torch.no_grad():
for x, y in val_dl:
x, y = x.to(device), y.to(device)
preds = model(x).argmax(1)
correct += (preds == y).sum().item()
total += y.size(0)
print(f"epoch {epoch+1}: val_acc={correct/total:.3f}")
torch.save(model.state_dict(), "spec_cnn.pt")
print("Saved spec_cnn.pt; classes =", train_ds.classes)
if __name__ == "__main__":
main()
5. The honest pitfalls of acoustic monitoring
- Site-correlated noise. A creek, a road, a generator — the network learns these and "detects" the site, not the species. Always evaluate on held-out sites.
- Distance effects. Closer calls produce higher scores, biasing "more activity" toward sites with closer perch trees. Calibrate per site.
- Annotation bias. If your training data was annotated by listening to high points in the day, your model will fail in low-activity periods.
- Temporal pseudo-replication. 300 detections from one bird singing for an hour is not 300 independent observations — aggregate before modeling.