Public-safety trunked-radio monitoring
How to monitor local public-safety dispatch with Trunk Recorder
- Tools
- Trunk Recorder, RTL-SDR Blog v4, Ubuntu 22.04 / Raspberry Pi OS bookworm
- Skill / setup
- intermediate · 3–5 hours (includes build from source, RadioReference lookup, systemd setup) · ~$175–$195 (RTL-SDR Blog v4 ~$40 + Raspberry Pi 5 8GB ~$80 + 32GB SD ~$10 + discone antenna ~$35 + powered USB hub ~$15)
Most public-safety agencies in the US dispatch over Project 25 Phase 1 trunked systems, and a regular RTL-SDR plus Trunk Recorder will pull those calls down, decode them, and tag every recording by talkgroup. This guide walks through getting a working install on a Pi or Linux box — control-channel decode, audio capture, and a systemd unit that keeps it running.
What you'll have at the end
A trunk-recorder process running as a systemd unit on your Linux box, locked to your county's control channel and writing per-call audio files to disk. Each call lands at /home/recorder/audio/<system>/<date>/<talkgroup>/<call_id>.m4a, tagged with talkgroup ID, start time, and source frequency. A second file — trunk-recorder.log — streams decoded control-channel updates so you can confirm the system is being tracked correctly. Optional uploaders push the same calls to OpenMHz or Broadcastify if you want them archived publicly. The whole thing survives reboots, recovers from short USB hiccups, and rotates audio under whatever retention policy you set. No browser tab, no babysitting.
What you need
Hardware. A Raspberry Pi 5 (8GB) or any x86 Linux box with at least 4GB of RAM. One RTL-SDR Blog v4 dongle (~$40) — the v4 is the right call over older v3 sticks because of its better front-end filtering on the 700/800 MHz public-safety bands. An SMA-to-MCX adapter and a discone antenna mounted outdoors (an attic install works in a pinch, but expect more dropped frames). A 32GB SD card if you're running on a Pi, a powered USB hub if you're going to add more dongles later for multi-system coverage, and a wired Ethernet drop if you can manage one — public-safety monitoring is the kind of workload where stable network beats Wi-Fi every time.
Software. Ubuntu 22.04 LTS on x86 or Raspberry Pi OS bookworm on a Pi. trunk-recorder built from source. gnuradio (Trunk Recorder's signal-processing dependency) plus its OOT modules. sox and ffmpeg for audio post-processing — m4a transcoding, optional WAV exports, that kind of thing.
Time. See the frontmatter at the top of this page. Skill level. Comfortable building C++ projects from source, editing JSON config, and running systemd units.
Step-by-step setup
1. Identify your local trunked system on RadioReference
Go to RadioReference (you don't need a premium account for the basic system lookup, but you'll want one for the full talkgroup export). Drill into Database → United States → your state → your county, then look for a section called something like "Trunked Systems." Most counties run one or two; the one labeled with your county's name plus "Public Safety" or "Sheriff" is almost always what you want.
Open that system's page and write down four things: the system name and ID (a hex value, e.g., 0x123), the NAC (network access code, also hex), the control channel frequencies (most P25 Phase 1 systems publish a primary control channel plus 2–4 alternates — Trunk Recorder will try them in order if the primary fails), and the band plan (the column labeled "Band Plan" or "Channel Spacing" — for almost all P25 Phase 1 systems this is 25 kHz, 851 MHz base or similar). Capture the full talkgroup list — names, decimal IDs, descriptions — to a scratch text file; you'll convert it to a CSV in step 6.
If your county runs something other than P25 Phase 1 — EDACS, MotoTRBO Connect Plus, NXDN, or P25 Phase 2 TDMA — stop here. Trunk Recorder supports all of those, but this guide is scoped to P25 Phase 1 FDMA, which is still the most common dispatch format in the US. The setup for the other system types is structurally similar, but the system block in config.json is different and the canonical-example link at the end of this guide doesn't apply.
2. Verify your local system is reachable with your hardware
Before installing anything else, plug in the RTL-SDR and run:
rtl_test -s 2400000
This confirms the dongle enumerates and that it can sample at 2.4 MS/s without USB underruns. If you see "lost samples" warnings every second, fix the USB cable or move to a powered hub before going further — Trunk Recorder is unforgiving about dropped samples.
Next, open gqrx and tune to your strongest control channel frequency. You should see a clearly visible carrier with a tight digital constellation — short, regular bursts roughly every second, not a continuous tone. Switch the demodulator to "Narrow FM" and you should hear the characteristic warble of a P25 control channel (it sounds like a fast digital churn, not voice). SNR should sit above ~15 dB; lower than that and you'll get partial decodes and silent audio files even when the log says everything is fine. If you can't see the control channel at all, the antenna is the problem 95% of the time — either it's not tuned for the 700/800 MHz band, or it's indoors without a clear line of sight to the tower. A discone gets you broadband coverage; a 700/800 MHz dedicated antenna gets you better SNR but only on those bands.
3. Install Trunk Recorder build dependencies
On Ubuntu 22.04 or Pi OS bookworm:
sudo apt update
sudo apt install -y git cmake build-essential libboost-all-dev \
libssl-dev libcurl4-openssl-dev libgnuradio-dev gnuradio-dev \
gr-osmosdr libosmosdr-dev rtl-sdr librtlsdr-dev libuhd-dev uhd-host \
libpulse-dev libsoxr-dev sox ffmpeg jq
This is the full list — don't skip packages assuming they'll resolve transitively. Several of Trunk Recorder's CMake find_package() calls fail with cryptic errors if these are missing.
4. Build Trunk Recorder from source
git clone https://github.com/robotastic/trunk-recorder.git
cd trunk-recorder
mkdir build && cd build
cmake ..
make -j4
sudo make install
On a Pi 5, drop -j4 to -j2 — make -j4 can OOM the linker on an 8GB Pi if anything else is running. The build takes 4–8 minutes on x86 and 12–20 minutes on a Pi.
5. Write config.json
Create ~/trunk-recorder/config.json. Below is a working P25 Phase 1 layout. You need to change the system frequencies, NAC, and shortName to your county's values; everything else can stay as-is for a first run.
{
"ver": 2,
"sources": [
{
"center": 851000000,
"rate": 2400000,
"error": 0,
"gain": 36,
"digitalRecorders": 4,
"driver": "osmosdr",
"device": "rtl=0"
}
],
"systems": [
{
"type": "p25",
"shortName": "CHANGE_ME",
"control_channels": [851012500, 851537500],
"nac": "0x123",
"talkgroupsFile": "talkgroups.csv",
"uploadScript": "",
"recordUnknown": true,
"audioArchive": true,
"uploadServer": "",
"modulation": "qpsk"
}
],
"captureDir": "/home/recorder/audio",
"logFile": true
}
A few of those fields are worth understanding rather than just copying:
centeris the center frequency the RTL-SDR will tune to, in Hz. It should sit roughly in the middle of your control channels and your voice frequencies; the dongle's usable bandwidth israte(2.4 MHz) wide aroundcenter. If your system's full allocation spans more than ~2 MHz, you'll need a second RTL-SDR with a differentcenterto cover the rest — Trunk Recorder handles multi-source configs natively.gainis in dB. 36 is a sensible start; go higher (40–49) if SNR is marginal, lower (25–30) if you're saturating because the tower is close.digitalRecordersis the number of simultaneous voice calls the source can decode. P25 Phase 1 systems rarely have more than 4 concurrent calls on the same RF source, so 4 is a reasonable cap; bumping to 6 doesn't cost much.recordUnknown: trueis the most important troubleshooting setting — it records any call whose talkgroup isn't in your CSV into anunknowndirectory, which is the only way to confirm decoding works before your talkgroup list is right.
6. Pull the talkgroups CSV
Trunk Recorder expects a CSV with this column order: Decimal,Hex,Alpha Tag,Mode,Description,Tag,Category,Priority. Easiest source is your county's RadioReference page (the CSV export is premium-only); alternatives are scraping the OpenMHz public dataset for your system, or hand-rolling a partial list from the wiki page.
Save the file as ~/trunk-recorder/talkgroups.csv. The file must be UTF-8 without a BOM — Excel exports BOMs by default, so if you edited it in Excel, re-save in a plain text editor.
7. First run
cd ~/trunk-recorder
trunk-recorder --config=./config.json
Success looks like: a banner with version info, the line Decoded control channel for system <yours> within ~10 seconds, then a stream of Update, Group Voice Channel Update, and Group Affiliation messages once the system goes active. Your first actual call recording lands within ~30 seconds on a busy system, longer on quiet ones (you'll see lines like [<talkgroup>] Recording call_<id>). Let it run for 5 minutes — long enough to capture a couple of cycles of routine traffic — then Ctrl-C and check /home/recorder/audio/<shortName>/ for .m4a files. Play one back with mpv or whatever; you should hear clear dispatch audio.
If the control channel decodes but no calls record at all, your talkgroup CSV is most likely empty or malformed (see the gotcha below). If the control channel doesn't decode within 30 seconds, kill the run and re-check your control-channel frequencies and gain — partial decodes look like the same Decoded control channel line followed by a flood of CRC failed warnings.
8. Daemonize with systemd
Create /etc/systemd/system/trunk-recorder.service:
[Unit]
Description=Trunk Recorder
After=network-online.target
[Service]
Type=simple
User=recorder
WorkingDirectory=/home/recorder/trunk-recorder
ExecStart=/usr/local/bin/trunk-recorder --config=/home/recorder/trunk-recorder/config.json
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable trunk-recorder
sudo systemctl start trunk-recorder
journalctl -u trunk-recorder -f
The journalctl tail mirrors what you saw in the foreground run. Restart=on-failure plus RestartSec=10 means a USB hiccup or a transient OOM gets the process back up in 10 seconds without you noticing. After=network-online.target matters if you're using uploadServer: it stops Trunk Recorder from trying to reach OpenMHz before DHCP has finished. The User=recorder line assumes you created a dedicated unprivileged user for this — recommended; do not run Trunk Recorder as root.
Common gotchas
Control channel locks but no calls record. The NAC in your config doesn't match what the system is actually transmitting. The hex value in RadioReference is sometimes stale or off by a nibble — verify by leaving recordUnknown: true set (you already have it that way above) and watching the log: a healthy decode logs the actual NAC every few seconds. Compare digit by digit.
Builds fail on Pi 5 with OOM (linker killed). You ran make -j4. Drop to -j2 for the link step. You can keep -j4 for the compile pass if you want a faster build — make -j4 || true to do the compile, then make -j2 to finish the link.
All calls land in an "Unknown" talkgroup directory. Trunk Recorder isn't reading talkgroups.csv. Three causes, in descending order of likelihood: (1) the file has a BOM — Excel did this; re-save in a plain editor as UTF-8 without BOM. (2) Wrong column order — the order above is mandatory. (3) Wrong path in config.json — talkgroupsFile is resolved relative to the working directory the binary was launched from, not relative to the config file itself.
Calls record but are pure static. The rate value in sources doesn't match what the RTL-SDR is producing. 2400000 (2.4 MS/s) is the right value for RTL-SDR Blog v4; older v2 sticks max out at 2048000 (2.048 MS/s) and need that value instead.
open failed on systemd start, despite working in foreground. Permissions. The User= value in the unit file doesn't have access to captureDir. Run sudo chown -R recorder:recorder /home/recorder/audio and restart the service.
What to do next
For decoding non-P25 trunked systems (P25 Phase 2, DMR Tier 3, NXDN), see Decode P25, DMR, and NXDN traffic with DSD+ — the same RTL-SDR can do it, with a different decoder downstream. For conventional VHF/UHF airband work, Listen to airband with SDR++ is the obvious next-door guide. If you want to share your feed publicly, OpenMHz's feeder setup walks through the upload script Trunk Recorder hands off to. Broadcastify's Feeder app is the commercial alternative. For diagnosing decode issues across multiple systems, Trunk Recorder ships its own analysis tools in the analysis/ directory of the repo.
Local DIY vs. Squelch Deck
| Dimension | Local DIY | Squelch Deck |
|---|---|---|
| Setup time | 3–5 hours | ~1 minute (tap the app) |
| Hardware cost | ~$175–$195 | One device |
| Ongoing maintenance | OS updates, dependency drift, debugging when it breaks | App updates roll through the Squelch Deck catalog |
| Customization ceiling | Total — you own the stack | Bounded by what apps support; you can build new apps |
| Skill required | Linux CLI, package management | Touchscreen |
| Best for | Tinkerers who want control + learning value | People who want it to work on a dedicated box |
Sources we drew from
- Trunk Recorder on GitHub — README and
INSTALL-LINUX.mdare the source of truth for build dependencies and the JSON config schema. - Trunk Recorder Wiki — talkgroups CSV format, source/system config fields, daemonization guidance.
- RadioReference — county and state database for control channels, NAC, and talkgroup lists.
- rtl-sdr.com tutorials — RTL-SDR Blog v4 setup, gain calibration, USB sample-rate verification.
- OpenMHz — public archive and feeder script for Trunk Recorder.
- A representative P25 Phase 1
config.jsonfrom the Trunk Recorderexamples/directory in the upstream repo.