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:

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

BirdNET confidence is not a probability. A score of 0.7 in one habitat does not mean the same thing as 0.7 in another. Always calibrate by hand-labeling a stratified subsample at a few thresholds for each site before you trust the species list.

3. PNW-Cnet

PNW-Cnet has two front-ends:

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:

FlagMeaning
-c v5Model version (v4 = 51 classes, v5 = 135 classes; default v5).
-r "CLASS thr CLASS thr ..." Per-class score thresholds for the review file.
-w NWorker processes for parallel spectrogram generation.
-aAuto-delete the temporary spectrogram folder when done.
-fProcess 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:

Output files

pycnet process writes three CSVs into the audio directory:

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

BirdNETPNW-Cnet (v5)
Geographic coverageGlobalPacific Northwest only
Class count6,000+135 (focused)
Time window3 s12 s, non-overlapping
Frequency rangeup to ~15 kHz0–4000 Hz
Best forSongbird species listsOwls, marbled murrelet, forest management indicator species
Calibration burdenHigh — many classes, generic priors Lower — calibrated for PNW soundscapes
GUI front-endBirdNET-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

→ Continue to Lab 6: BirdNET on a dawn chorus