Part 1 of The Operator’s Cookbook, a 14-part series of complete, copy-paste GopherTrunk builds — one working rig per part, antenna to browser. Every other tutorial series on this blog is organized by subsystem — RF, DSP, recording, operations. This one is organized by what you’re trying to build: each part is one full recipe, hardware list to working config to the log lines that prove it’s alive. The running thread is a rig that grows — the $40 starter you build today is the same box that streams to Broadcastify in Part 9 and runs headless in a closet by Part 11. We start with the most common first build there is: one cheap dongle on a local P25 Phase 1 system.
TL;DR: One RTL-SDR Blog V3/V4 (~$35), the whip it ships with, and a laptop decode a P25 Phase 1 system end to end. The trick that makes a single dongle work is
role: wideband+voice_taps: 2— the control channel and the voice grants all decode out of one 2.4 MS/s capture, no second radio. You find your system on RadioReference (or withgophertrunk hunt), paste its control channels intoconfig.yaml, rungophertrunk run -config config.yaml, and watch forcontrol channel lockedthenrecorder: call startedin the log. The web console athttp://127.0.0.1:8080does the rest.
Key takeaways
- $40 is a real number, not a teaser. A ~$35 RTL-SDR with a TCXO plus the antenna in the box decodes P25 Phase 1 cleanly on any system you have reasonable signal from. The upgrades in later parts buy margin, not possibility.
- One dongle carries control and voice.
role: widebandwithvoice_taps: 2taps voice calls out of the same IQ capture the control channel decodes from — as long as the system’s voice channels fit inside the 2.4 MHz window. - The config is four blocks. SDR device, one system, its control channels,
and where recordings go. Everything else in
config.example.yamlis optional and defaulted. - Healthy has a signature.
control channel locked→call started→recorder: call endedis the heartbeat of a working rig; this post shows the real lines so you know them on sight.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Hardware list | dongle + whip + adapter, well under $100 total | what do I need? |
| Which dongle | V3 vs V4 — either decodes identically | V3 vs V4 guide, RTL-SDR |
| One-dongle trunking | wideband capture + per-call voice taps | sdr.devices[].role: wideband, voice_taps |
| System definition | protocol, control channels, talkgroup names | trunking.systems[], control_channels, talkgroup_file |
| Finding your system | RadioReference, or map it yourself | gophertrunk hunt |
| Frequency error | per-device ppm, or let autotune suggest one |
ppm correction |
| First-run cockpit | web console on the API port | api.http_addr, web guide |
In this post
- What you’re building — one dongle, one P25 system, calls on disk and in the browser.
- The shopping list — three items, round numbers.
- Finding your system — RadioReference first,
huntwhen it isn’t listed. - The config — a complete, minimal
config.yamlwith every key verified. - First run — what healthy looks like — the exact log lines and web panels.
- When it doesn’t work — symptom → cause → fix.
What you’re building
The finished rig is a laptop with a USB dongle hanging off it, doing what a $500 digital scanner does: camp a P25 Phase 1 control channel, decode every voice grant, follow calls to their voice channels, decode the IMBE audio, and write one WAV per call into a folder tree sorted by talkgroup — with a live web console showing the system light up in real time.
The architectural trick that makes one dongle enough: GopherTrunk’s wideband
engine treats the dongle’s whole 2.4 MHz capture as a band, not a channel. One
tap inside that capture decodes the control channel continuously; when a voice
grant arrives, a voice_taps slot spins up a second down-converter on the
same IQ stream at the granted frequency. No retuning, no second radio —
provided the voice channels land inside the window. (When your system spreads
wider than 2.4 MHz, that’s the two-dongle variation at the end.)
The shopping list
| Item | Price (rough) | Notes |
|---|---|---|
| RTL-SDR Blog V3 or V4 | ~$35 | TCXO matters — ppm: 0 actually holds. V3 vs V4 |
| Antenna | $0 | the kit’s telescopic whip — extend ~one-quarter wavelength for your band |
| Computer | $0 | any laptop/desktop from the last decade; a Pi works too (Part 11) |
That’s it — about $40 shipped. A no-name dongle without a
TCXO still works; you’ll just meet
the ppm troubleshooting row sooner. The
full hardware checklist
covers adapters and upgrade paths; resist all of them until the rig works.
Finding your system
You need two facts: your local system’s protocol (this recipe wants P25 Phase 1) and its control channel frequencies. RadioReference’s database has both for nearly every public-safety system in North America — copy every frequency marked as a control channel (they rotate; list them all).
No listing, or abroad? GopherTrunk maps unknown systems itself:
gophertrunk hunt -serial 00000001 -band 851:869
sweeps the band, identifies control channels, decodes their identity, and
exports a ready-to-merge config (-commit writes it into config.yaml for
you). The whole discovery pipeline has its own series —
The Hunt
— and Part 7
walks a P25 lock specifically. New to trunking as a concept? The
scanning module is the ten-minute
primer on why one control channel governs a whole system.
The config
Plug in the dongle and confirm GopherTrunk sees it:
gophertrunk sdr list
Note the serial. Then this is the entire config.yaml — every key verified
against config.example.yaml:
log:
level: info
storage:
path: "../data/calls.db"
recordings:
dir: "../recordings"
sdr:
sample_rate: 2_400_000
devices:
- serial: "00000001" # from `gophertrunk sdr list`
role: wideband
gain: "auto"
center_freq_hz: 858_000_000 # middle of YOUR system's channels
voice_taps: 2
channels:
- frequency_hz: 857_262_500 # your control channel
system: "Metro-P25"
trunking:
systems:
- name: "Metro-P25"
protocol: p25
control_channels:
- 857_262_500
- 858_487_500 # list the alternates too
talkgroup_file: "../config/talkgroups-p25.csv" # optional
Three decisions worth explaining. center_freq_hz should sit near the
middle of your system’s full frequency list (control and voice channels), so
as many as possible fall inside the ±1.2 MHz window — a channel must be within
center ± sample_rate/2 with a 5% edge guard, and voice grants outside it
can’t get a tap. gain: "auto" is deliberate: gain is in tenths of a dB
in this file ("496" = 49.6 dB), the single most common config typo, and AGC
sidesteps it entirely until Part 6 of The Analog
Edge
teaches you to stage it by hand. talkgroup_file is optional — a
RadioReference-style CSV with a Decimal column plus optional Alpha Tag,
Description, Tag, Priority columns. Skip it for now; calls record by
number, and Part 13 is entirely about naming things.
First run — what healthy looks like
gophertrunk run -config config.yaml
Within a few seconds, three log lines tell the whole story. First the API comes up:
INF api: listening addr=127.0.0.1:8080 tls=false
Then — this is the moment — the control channel decoder finds P25 frame sync and reads the network ID:
INF control channel locked nac=659 freq=857262500 rot=0 delta=0.02
nac is the system’s Network Access Code; check it against RadioReference to
confirm you’re on the system you think you are. From here the rig is a
spectator to every grant. When someone keys up:
INF call started device=cc:wideband:... grant=... priority=5
INF recorder: call started device=... wav=../recordings/Metro-P25/9001/... tg=9001 provoice=false vocoder=imbe
INF recorder: call ended device=... wav=... duration=4.86s reason=released
Open http://127.0.0.1:8080 and you get the same story visually: the
Dashboard shows the locked system, Active lights up per call with live
audio if you want it, and History is your searchable call log. The web
guide tours every panel; the
TUI gives you the same cockpit in a
terminal for the Part 11 headless build.
Let it run ten minutes. A quiet system is normal; no
control channel locked line is not — which brings us to:
When it doesn’t work
| Symptom | Likely cause | Fix |
|---|---|---|
cchunt: hunt failed — no control-channel lock with a diagnosis field |
wrong/stale CC frequencies, or no signal | Read the diagnosis — it distinguishes dead IQ from undecodable IQ. Re-check RadioReference, list all CC alternates, or run gophertrunk hunt -candidates on them |
| Locks briefly, drops, hunts again | marginal signal or gain mis-staged | Move the whip to a window; see gain staging before buying anything |
ccdecoder: control carrier offset far from configured frequency (issue #815) |
crystal ppm error, or you’ve locked an adjacent site’s stronger carrier | Set the device ppm, or turn on sdr.autotune: true and paste the value it suggests; verify the reported site identity |
Locked, but TSBK blocks are failing at a high rate while tuned at zero-IF (issue #402) |
the front end’s DC spur sits on your control channel | Set dc_avoid: true on the device — GT tunes the LO off-channel and mixes back, like SDRTrunk/OP25 |
CC locked, grants logged, but no voice device available for grant |
granted voice channel is outside the 2.4 MHz window | Re-pick center_freq_hz, or add a second dongle as role: voice (variation below) |
| Dongle vanishes mid-run, then reappears | USB power/hub flakiness | The watchdog (sdr.watchdog_interval_ms) re-acquires by serial automatically; use a rear-panel port, no hub — see the USB watchdog deep dive |
| Everything decodes but audio sounds thin/quiet | nothing is wrong — faithful decode is conservative | recordings.enhance.enabled: true for the louder OP25-style chain |
One principle from the deep-dive side of the house applies on day one: when decode is bad, the samples are usually bad first. The decoder can only be as good as the samples — a rig that won’t lock wants a better window sill before it wants a config change.
How this recipe shapes operator practice
- Trust log lines over vibes. Each stage of this rig announces itself with a specific string. Learn the healthy trio now; every later part’s troubleshooting table is written against lines like them.
- Change one knob at a time. The config above has exactly three numbers you chose (serial, center, control channels). When it misbehaves, that’s your entire search space — keep it that way as the rig grows.
- Keep
config.example.yamlopen. Every key in this series exists there with a comment; it’s the authoritative reference between cookbook parts.
Variations
- Two dongles, classic split. Delete the wideband block; give one device
role: controland a secondrole: voice(that’s thedevices:shape at the top ofconfig.example.yaml). No window limit — voice grants can land anywhere the voice dongle can tune. Cost: one more ~$35 dongle. - Monitor-only.
role: controlalone, no voice device: you get the full control-channel picture (grants, talkgroups, the CC panel) with zero voice. Great for reconnaissance on a new system. - More concurrent calls. Raise
voice_taps— CPU scales roughly linearly per tap, and the daemon warns above 16. Two is right for a starter system. - Better antenna. The single highest-value upgrade, and Part 7 of The Analog Edge plus the antenna guide cover it — but make the whip work first so you have a baseline.
Where this goes next
This rig assumed the friendliest protocol in the fleet. Part 2 points the same hardware at a DMR Tier III network — where the control channel hands out logical channel numbers instead of frequencies, and the config needs a band plan (or GopherTrunk’s ability to learn one off the air) before a single call records.
FAQ
Can I really decode P25 with a $35 RTL-SDR? Yes — P25 Phase 1 at 4800 baud is well within an RTL-SDR’s dynamic range and stability, and GopherTrunk’s whole P25 path was built and tested against exactly this hardware. What the cheap dongle costs you is margin on weak systems, which is what the antenna and RF parts of this series buy back.
Do I need one dongle or two for a trunked system?
One, if your system’s control and voice channels fit inside the dongle’s
2.4 MHz window — the role: wideband + voice_taps config above decodes
both from a single capture. Two, if the system spans more spectrum than that:
keep the wideband CC tap and add a role: voice dongle for out-of-window
grants.
How do I find my local P25 control channel frequency?
RadioReference lists control channels for most North American systems — use
every frequency flagged as a primary or alternate CC. Without a listing, run
gophertrunk hunt with a -band sweep; it finds control channels by decoding
them, not by guessing from the license database.
Why is GopherTrunk’s gain setting in tenths of a dB?
It matches the raw units the tuner driver speaks, so "496" means 49.6 dB.
If you’re translating a gain figure from SDRTrunk, OP25 or gqrx, multiply by
ten — or start with gain: "auto" like this recipe and skip the issue.
Does this rig decode encrypted P25 calls?
No — encrypted calls are flagged and logged with their metadata (talkgroup,
source, algorithm ID), but GopherTrunk does not decrypt P25. The per-system
encrypted_calls policy controls whether encrypted grants tie up your voice
taps at all; on a starter rig the default is fine.
Series navigation
Part 1 of 14 · Next → Part 2: A DMR Tier III Network, End to End