Part 6 of The Operator’s Cookbook, a 14-part series of complete,
copy-paste GopherTrunk builds — one working rig per part, antenna to browser.
Part 5
decoded TETRA radios talking directly to each other; this part drops the last
digital layer entirely. Plenty of the traffic worth hearing — volunteer fire
dispatch, marine VHF, GMRS repeaters, race crews — is still plain analog
FM, and GopherTrunk scans it the way a hardware scanner does: a channel
list, real squelch, sub-audible tone gating, and a two-tone detector that
fires an alert the instant your station’s pager tones hit the air. One
honest reshaping up front: analog channels live in their own config section,
not under trunking.systems — this recipe is built on what actually exists.
TL;DR: Analog FM is a scan list, not a trunked system: channels go under
scanner.conventional(label,frequency_hz,mode: fm|nfm,squelch_dbfs,hangtime_ms, per-channel CTCSS/DCStone:gating), and the scanner drives a dedicatedrole: voiceSDR — the last voice device in the pool. Each open-squelch dwell becomes a synthetic call (synthetic call startedin the log), recorded like any digital call. On top of the decoded audio,tone_out.profilesruns a Goertzel two-tone detector for Quick Call II fire paging and logstoneout: profile matched. Pin atalkgroup_idper channel or your call log’s IDs shift when you reorder the list (#1105).
Key takeaways
- Conventional analog is its own config section.
scanner.conventionalis a fixed-frequency scan list with per-channel squelch, priority and hangtime — deliberately shaped like the scan list in a hardware scanner, not like a trunked system. - The scanner needs a voice SDR to own. It takes the last
role: voicedevice in the pool; with none configured the daemon logs a WARN and skips the scanner entirely — the most common “why is my scan list dead” cause. - Tone gating is what makes shared frequencies usable. With CTCSS or DCS configured, the scanner dwells only when carrier and the right tone are present, so a co-channel system two counties over can’t hold your scanner hostage.
- Tone-out listens to audio, not RF. The two-tone detector runs on decoded PCM from any voice device — so it catches pages on your analog scan list and on digital talkgroups alike, scoped by profile.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| The scan list | fixed analog channels, scanned in order | scanner.conventional[] |
| Squelch | carrier gate in dBFS, per channel | squelch_dbfs (squelch) |
| Tone gate | require CTCSS/DCS before dwelling | tone: {mode, ctcss_hz, dcs_code, dcs_polarity} |
| Stable call-log IDs | pin the channel’s talkgroup number | talkgroup_id (else synthetic, shifts on reorder — #1105) |
| Fire paging alerts | Quick Call II two-tone sequential detect | tone_out.profiles[] |
| Live audio | hear dwells on the host speakers | audio.enabled: true |
| Where the frequencies are | US service-by-service listings | scanner frequencies guide |
In this post
- What you’re building — a software scanner with a pager bolted on.
- The shopping list — one dongle, one band-appropriate antenna.
- The config — scan list + tone-out profiles, every key verified.
- First run — what healthy looks like — dwells, synthetic calls, a matched page.
- When it doesn’t work — symptom → cause → fix.
- Variations — priority channels, manual VFO, digital-side tone-out.
What you’re building
The finished rig scans a list of conventional analog channels — say a
volunteer fire dispatch frequency, the county EMS channel, and
marine VHF 16 — the way a
$150 hardware scanner does: tune, measure squelch, move on; when a carrier
(and, if configured, its tone) appears, dwell, demodulate FM, and record
until the carrier drops plus a hangtime. Every dwell becomes a call in the
History panel with a WAV on disk, indistinguishable from the digital calls
of Parts 1–5 except for its protocol tag (fm-conv).
Under the hood this path is simpler than anything else in the series — FM discriminator to PCM, no vocoder — but it earned its own postmortem: the conventional channel used to be its own voice channel in a way the IQ plumbing didn’t expect, and the broker fix from that story is why live monitoring, recording and the tone-out detector all listen to the same dwell today. The wider conventional architecture is Protocol Decoders Part 9’s territory.
The second half of the build is tone-out: US fire/EMS dispatch still
pages stations with Motorola Quick Call II — an A tone (~1 s) then a longer
B tone. GopherTrunk runs a Goertzel detector across decoded voice PCM and
fires a KindToneAlert event when a profile matches — surfaced in the web
Tones panel and the log. (The encoder side of this audio machinery
lives in
Voice Coding Part 11.)
The shopping list
| Item | Price (rough) | Notes |
|---|---|---|
| RTL-SDR Blog V3/V4 | ~$35 | analog FM is the least demanding decode in this series |
| VHF-capable antenna | $0–$30 | fire dispatch and marine live at 150–162 MHz — extend the kit whip fully, or a quarter-wave for the band |
| Computer | $0 | anything; this path is the lightest on CPU too |
Frequencies come free: the scanner frequencies guide lists US police/fire/EMS, marine, rail and GMRS allocations by service, and the fire & EMS guide covers which fire traffic tends to stay analog. Tone pairs for tone-out come from your agency’s dispatch records or from listening to a few pages.
The config
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: voice # the conventional scanner drives a Voice SDR
gain: "auto"
scanner:
conventional:
- label: "County Fire Dispatch"
frequency_hz: 154_190_000
mode: fm
squelch_dbfs: -48
hangtime_ms: 1500
priority: 3
talkgroup_id: 2147500001 # pin it — see below
tone:
mode: ctcss
ctcss_hz: 110.9
- label: "Marine VHF 16"
frequency_hz: 156_800_000
mode: nfm
squelch_dbfs: -50
hangtime_ms: 1500
priority: 5
talkgroup_id: 2147500002
audio:
enabled: true # hear dwells on the host speakers
tone_out:
profiles:
- name: "station-1-engine"
alpha_tag: "Station 1 Engine"
cooldown: "30s"
tones:
- frequency_hz: 1042.2
min_duration: "250ms"
max_duration: "1500ms"
- frequency_hz: 1297.4
min_duration: "2.5s"
max_duration: "5s"
Decisions worth understanding. role: voice is mandatory — the
conventional scanner steals the last voice device in the pool; on a
pure-analog rig like this one, that’s your only dongle, which is fine.
mode: nfm vs fm narrows the post-demod audio path for
12.5 kHz-channelized services (most modern licensing); when audio sounds
muffled or clipped, you’ve likely mismatched the channel’s
deviation class.
squelch_dbfs is an absolute IQ-power gate — start around −48 and
tune per channel while watching dwell behavior. talkgroup_id deserves
its comment: an unset channel gets a positional synthetic ID
(0x80000000 | list index) that shifts when you reorder, insert or remove
channels, silently orphaning your call-log history and talkgroup-file rows
— issue #1105’s lesson. Pin explicit IDs from day one. And the tone:
block makes the gate require carrier and tone: without it, any carrier
above squelch holds the scanner, including a distant system sharing the
frequency.
The tone-out profile is Quick Call II shaped: tone A ~1 s, tone B ~3 s,
with tolerance_hz (default 15), magnitude_threshold (default 0.05) and
max_gap (default 200 ms) available when the defaults misbehave, and a
cooldown to stop one long page re-firing the alert.
First run — what healthy looks like
gophertrunk run -config config.yaml
The scan loop starts silently — an idle list is just the tuner stepping channels a few times a second. The moment a carrier with the right tone appears, the dwell shows up as a synthetic call:
INF synthetic call started device=00000001 grant=...
INF recorder: call started device=00000001 wav=../recordings/.../2147500001/... tg=2147500001 provoice=false
INF recorder: call ended device=00000001 wav=... duration=8.32s reason=...
INF synthetic call ended device=00000001 reason=...
Open the web console: the dwell plays live in Active and lands in History under the pinned talkgroup ID. When a page matches a profile:
INF toneout: profile matched profile=station-1-engine device=00000001 tones=[1042.2 1297.4]
— and the Tones panel logs the alert with its alpha tag. If the scanner has no device to run on, the daemon tells you at startup instead of failing quietly:
WRN daemon: scanner.conventional / manual_tune_enabled configured but no Voice SDRs in the pool; skipping
That WARN is the first thing to grep for when the scan list seems dead.
When it doesn’t work
| Symptom | Likely cause | Fix |
|---|---|---|
| Scan list configured, nothing ever dwells, WARN above at startup | no role: voice device in the pool |
Give the scanner a voice SDR. On a combined trunked+analog rig, add a second voice dongle — the scanner takes the last one |
| Scanner parks forever on one channel | squelch open on noise or a constant carrier (data link, remote base) | Raise that channel’s squelch_dbfs; better, add a tone: gate so only your agency’s traffic opens it |
| Dwells on someone else’s traffic on a shared frequency | carrier squelch alone can’t tell users apart | Configure the channel’s CTCSS tone or DCS code — the gate then needs carrier AND tone |
conv: CTCSS detector failed to initialise; tone gate disabled — every signal passes the gate |
bad tone parameters for this channel/rate | Check ctcss_hz is a standard EIA tone (67.0–254.1) / dcs_code a 3-digit octal; the scanner fails open, so fix it or you’re back to carrier squelch |
| Audio muffled, or loud voices clip | deviation/bandwidth mismatch | Flip mode: between fm and nfm to match the service’s channelization |
| History IDs changed after editing the list | synthetic positional IDs shifted (#1105) | Pin talkgroup_id on every channel — one-time fix, durable forever |
Pages audible but no toneout: profile matched |
tone frequencies off, or the B tone shorter than min_duration |
Verify the pair against dispatch records; widen tolerance_hz, relax durations. The detector needs the whole A→B sequence within max_gap |
| Alert fires twice per page | long pages re-trigger | Lengthen the profile’s cooldown |
One inherited lesson applies unchanged from the digital parts: gain staging still decides everything downstream. A squelch threshold tuned against a badly staged front end is a number about your gain knob, not about the channel.
How this recipe shapes operator practice
- Pin identity early.
talkgroup_idandlabelcost nothing on day one and save your call history the first time you touch the list. - Prefer tone gates to tight squelch. A tone answers “is it my agency?”; squelch only answers “is it loud?”.
- Let the WARNs work. This path fails loudly and open — the startup WARN and the tone-gate-disabled WARNs are designed to be grepped, not ignored.
Variations
- Manual VFO.
scanner.manual_tune_enabled: truebuilds the scanner even with no channel list, so the TUI’sfkey (orPOST /api/v1/scanner/manual_tune) can tune anything ad hoc — a software VFO on a spare voice dongle. - Priority channels. Per-channel
priorityis honored by the engine’s preemption against other calls — set your dispatch channel higher than routine traffic (more in Part 7). - Digital-side tone-out. Scope a profile with
systemandgroup_idto catch pages that go out over a trunked talkgroup. - Actual pagers. For POCSAG/FLEX paging protocols (not voice tones),
the separate
paging:section dedicates or shares an SDR per paging frequency — same rig, different decoder.
Where this goes next
You now have five kinds of rig and only ever one system per box. Part 7 scales out: several dongles, several trunked systems, one daemon — device serials and roles, sizing the shared voice pool against concurrent calls, and the priority/preemption rules that decide who gets a radio when calls outnumber tuners.
FAQ
Can GopherTrunk scan regular analog FM channels like a police scanner?
Yes — scanner.conventional is a real scan list: fixed frequencies, carrier
squelch in dBFS, optional CTCSS/DCS gating, per-channel hangtime and
priority, with each dwell recorded and logged as a call. It needs one
role: voice SDR to drive.
How do I decode two-tone fire pager tones with an SDR?
Give GopherTrunk the A/B tone pair in a tone_out.profiles entry — the
Goertzel detector watches decoded audio from your channels and fires an
alert (log line, event, web Tones panel) when the sequence matches. Tone
pairs come from your agency’s dispatch documentation or from measuring a
recorded page.
What’s the difference between squelch_dbfs and a CTCSS tone gate?
squelch_dbfs opens on any carrier above a power threshold; a CTCSS/DCS
gate additionally requires the sub-audible tone your agency transmits. On a
shared or noisy frequency, tone gating is the difference between a usable
scan list and a scanner held open by strangers.
Why do my conventional channels show weird talkgroup numbers?
Unpinned channels get a synthetic ID built from their list position
(0x80000000 | index), which changes when the list changes. Set an explicit
talkgroup_id per channel to keep History rows and talkgroup-file aliases
stable — issue #1105 exists because reordering silently broke both.
Can tone-out watch digital talkgroups too?
Yes — the detector runs on decoded PCM regardless of protocol, and a
profile scoped with system and group_id matches pages sent over a
trunked talkgroup as readily as over analog dispatch.
Series navigation
Part 6 of 14 · ← Part 5: TETRA Direct Mode — Scanning DMO · Next → Part 7: Many Systems, One Box — The SDR Pool