GopherTrunk on Windows — User Guide

This guide takes a Windows 11 operator from a fresh download to a fully configured, daily-driven scanner. It consolidates the install walkthrough, every CLI subcommand, every config.yaml knob, the TUI and web console, importing systems from RadioReference, hardening, voice-decoder calibration, and troubleshooting into one Windows-focused reference.

Where this document distills information from elsewhere in the docs, the source page is linked inline. Operators on Linux or macOS should prefer the platform-specific install guides (Linux, macOS) plus the shared TUI / web console / hardening pages.

Contents

  1. What you need before you start
  2. Install GopherTrunk
  3. Bind the RTL-SDR to WinUSB with Zadig
  4. Verify the install
  5. Build your first config
  6. Run the daemon
  7. Configure as a Windows service
  8. Operate from the TUI
  9. Operate from the web console
  10. Import trunked systems from RadioReference
  11. Configuration reference (config.yaml)
  12. Scanner subsystems
  13. Audio playback and recordings
  14. Tone-out paging-tone alerts
  15. API authentication and hardening
  16. Metrics and health endpoints
  17. CLI reference
  18. Vocoders and voice calibration
  19. Upgrading and uninstalling
  20. Troubleshooting

1. What you need before you start

  • Windows 11 x64 (Windows 10 x64 22H2 also works, untested below that). The portable ZIP also has an ARM64 variant for Surface / Snapdragon X laptops.
  • An RTL-SDR dongle. Any RTL2832U-based dongle with an R820T, R820T2, R828D, E4000, FC0012/13, or FC2580 tuner. The reference units are the NooElec NESDR Smart v5 and RTL-SDR Blog v3 / v4. See hardware.md for the full matrix.
  • Administrator access for the install wizard and the one-time Zadig driver swap.
  • Windows Terminal or PowerShell. Every shell command below is PowerShell.
  • A modern browser (Edge, Chrome, Firefox, or any Chromium fork) if you plan to use the web operator console.

2. Install GopherTrunk

The full walkthrough lives at install-windows.md. The condensed version:

  1. Download gophertrunk-<version>-windows-amd64-setup.exe from the releases page. The matching …-windows-amd64.zip is identical contents if you’d rather skip the wizard. ARM64 portable ZIPs are also published.

  2. Verify the SHA-256 checksum before running the installer (see downloads.html#verify-your-download):

    $expected = (Get-Content SHA256SUMS | Select-String "windows-amd64-setup.exe").ToString().Split(" ")[0]
    $actual = (Get-FileHash gophertrunk-<version>-windows-amd64-setup.exe -Algorithm SHA256).Hash.ToLower()
    if ($actual -ne $expected) { throw "checksum mismatch" }
    
  3. Run the installer. Builds are unsigned today — if SmartScreen blocks the download or run, click More info → Run anyway, or right-click the file → Properties → check Unblock. The wizard does the following:

    • Copies gophertrunk.exe to C:\Program Files\GopherTrunk\ (single static binary, no DLLs). That is the only thing in Program Files.
    • Asks for one data folder (default %USERPROFILE%\Documents\GopherTrunk) that holds everything else, in subfolders Setup creates — config\, recordings\, iq\, exports\, data\, logs\, and web\ (the three browser consoles). Setup seeds a starter config.yaml into config\ (an existing one is never overwritten). Point it anywhere you can write to.
    • Bundles Zadig next to the daemon and adds a Start Menu shortcut “Install RTL-SDR driver (Zadig)” so you don’t have to chase a separate download.
    • Adds Start Menu entries for the daemon, the config template, the three web consoles (standard, Signal Lab, Config Builder), and the install walkthrough.
    • Optionally adds C:\Program Files\GopherTrunk to your system PATH so gophertrunk is reachable from any PowerShell window (off by default — tick the “Add GopherTrunk to my PATH” option during install if you want it).

When the wizard finishes it offers to open this guide, a console window, Zadig (to bind the WinUSB driver — see §3), and (if installed) the web console.

3. Bind the RTL-SDR to WinUSB with Zadig

Windows ships an RTL-SDR DVB-T driver by default — that’s the broadcast-TV driver, and it’s the wrong driver for SDR work. You need to swap it to WinUSB on a per-device basis with Zadig. The installer bundles Zadig (GPL-3.0, from https://zadig.akeo.ie), so you don’t have to chase a download. You only do this once per dongle.

  1. Plug in the RTL-SDR dongle.
  2. Launch Zadig via Start Menu → GopherTrunk → “Install RTL-SDR driver (Zadig)”. Approve the UAC prompt. (Or tick the “Run Zadig now to bind the WinUSB driver” option on the installer’s last page before clicking Finish.)
  3. Options → List All Devices so the RTL-SDR shows up.
  4. From the dropdown, pick the dongle — typically Bulk-In, Interface (Interface 0) or RTL2832U (the NESDR Smart v5 reports as RTL2838UHIDIR).
  5. With WinUSB selected as the target driver, click Replace Driver (or Install Driver on first run).
  6. Wait ~10 seconds for the success dialog.

To restore the original DVB-T driver later (e.g. to watch broadcast TV again), re-run Zadig and pick the manufacturer driver.

4. Verify the install

Open Windows Terminal and run:

gophertrunk version
gophertrunk sdr list

gophertrunk version prints the build version, git SHA, and build timestamp (all pinned at link time via -ldflags).

sdr list prints one row per attached dongle with its driver, index, serial, and product string. The TUNER and gains columns are blank by default — sdr list only reads USB descriptors, so it’s fast and never collides with a running daemon. Pass --probe when you want those columns populated (it opens each device briefly to run the demod + tuner bring-up):

> gophertrunk sdr list
DRIVER    IDX  SERIAL            TUNER     PRODUCT   gains(0.1 dB)
rtlsdr    0    00000001                    Generic   []

> gophertrunk sdr list --probe
DRIVER    IDX  SERIAL            TUNER     PRODUCT   gains(0.1 dB)
rtlsdr    0    00000001          R820T2    NESDR Sm  [0 9 14 ... 496]

If you see no SDR devices found:

  • Confirm the dongle is plugged in (LED on, Device Manager shows it).
  • Re-run Zadig with Options → List All Devices and verify the Driver column shows WinUSB. If it shows RTL2832UUSB / RTL28xxBDA, the swap didn’t take.
  • If you didn’t add GopherTrunk to PATH, run from the install folder:
    cd "C:\Program Files\GopherTrunk"
    .\gophertrunk.exe sdr list
    
  • Run gophertrunk sdr doctor (§ 4.1 below) to see exactly which driver Windows has bound to the dongle and what to do about it.

4.1 gophertrunk sdr doctor — per-dongle driver report

sdr list only shows dongles whose driver is already WinUSB. When a dongle is plugged in but missing from sdr list, sdr doctor walks the Windows USB tree, reads the function driver currently bound to each known RTL-SDR VID/PID, and prints a row per device with a concrete next step. It never claims or opens the device, so it’s safe to run alongside a live daemon and as a non-admin user.

gophertrunk sdr doctor

Example output for a dongle that hasn’t been Zadig’d yet:

VID:PID    SERIAL    DRIVER       EXPECTED  STATUS  NEXT-STEP
0bda:2838  00000001  RTL2832UUSB  WinUSB    BAD     Windows in-box DVB-T driver is bound. Run Zadig from the Start Menu, …

For a working dongle:

VID:PID    SERIAL    DRIVER  EXPECTED  STATUS  NEXT-STEP
0bda:2838  00000001  WinUSB  WinUSB    OK      -

Pass -v for the extra DESCRIPTION column (the Device Manager friendly description), useful when several dongles share VID:PID and you need to disambiguate which one Zadig touched.

4.2 Windows 11-specific driver-binding failure modes

Windows 11 has several mechanisms that can prevent the WinUSB driver from binding correctly even after a Zadig run. Match the symptom in your sdr doctor output to the relevant subsection below.

Core Isolation / Memory Integrity blocks the WinUSB INF

Symptom: Zadig’s install dialog appears to succeed, but sdr doctor still shows RTL2832UUSB (or the dongle vanishes from Device Manager with a “driver couldn’t be installed” toast).

Cause: Windows 11’s Memory Integrity (a Core Isolation feature) rejects driver catalogs that don’t carry an HVCI-compatible signature. Older Zadig builds shipped an INF the HVCI verifier rejects.

Fix:

  1. Settings → Privacy & security → Windows Security → Device security → Core isolation → toggle Memory integrity off and reboot.
  2. Re-run Zadig; the WinUSB swap will land.
  3. (Optional, recommended) Toggle Memory integrity back on after the driver is bound — Windows keeps the existing binding across the re-enable.

Smart App Control blocks the unsigned driver install (Win11 22H2+)

Symptom: Zadig itself refuses to launch, or the driver install fails with “Windows blocked this app to keep your device protected.”

Cause: Smart App Control in Enforced mode blocks unsigned-catalog driver installers. Smart App Control cannot be re-enabled once disabled without reinstalling Windows, so disable only if you’re comfortable with that trade-off.

Fix: Settings → Privacy & security → Windows Security → App & browser control → Smart App Control settings → switch to Off or Evaluation (not Enforced). Re-run Zadig.

Driver Signature Enforcement blocks older Zadig builds

Symptom: Zadig fails with “INF third-party driver was not digitally signed.”

Cause: Older Zadig builds bundle a self-signed catalog Windows 11 won’t accept under default Driver Signature Enforcement.

Fix: Use the Zadig bundled by the GopherTrunk installer (it ships a signed catalog). If you must use an older Zadig, boot to Recovery → Advanced startup → Startup Settings → press 7 (Disable driver signature enforcement) for a one-shot install.

Windows Update re-installs the DVB driver after feature updates

Symptom: sdr doctor showed WinUSB before a major Windows release (22H2 → 23H2 → 24H2); after the upgrade it shows RTL2832UUSB again and the daemon fails to open the dongle.

Cause: Feature updates re-evaluate driver matching for every USB device and can override Zadig’s binding with the in-box DVB driver.

Fix (one-shot): Re-run Zadig.

Fix (permanent, requires Pro/Enterprise): gpedit.msc → Computer Configuration → Administrative Templates → System → Device Installation → Device Installation Restrictions → Prevent installation of devices that match any of these device IDs → enable, and add:

USB\VID_0BDA&PID_2838
USB\VID_0BDA&PID_2832

(Add any other rows from sdr doctor that match dongles you own.)

Multi-dongle: only one was Zadig’d

Symptom: sdr doctor shows mixed status — OK for one row and RTL2832UUSB for the rest.

Cause: Zadig binds one device at a time. Each dongle (each USB device-instance, even of the same model) needs its own driver swap.

Fix: Re-run Zadig, Options → List All Devices, and rebind every row whose serial appears in sdr doctor with status BAD. The device-instance is keyed by serial number, so a swap for serial 00000001 doesn’t carry over to serial 00000002.

Composite-device parent is bound instead of Interface 0

Symptom: sdr doctor shows usbccgp as the driver.

Cause: In Zadig you picked the USB Composite Device parent node instead of the child Bulk-In, Interface (Interface 0) entry. The RTL2832U firmware exposes a single bulk interface; the WinUSB binding has to land on the child, not the composite parent.

Fix: Re-run Zadig, Options → List All Devices, and pick the Bulk-In, Interface (Interface 0) entry (typically named RTL2832U or RTL2838UHIDIR). Confirm the destination box still shows WinUSB, then click Replace Driver.

Zadig installed libusbK or libusb-win32 instead of WinUSB

Symptom: sdr doctor shows libusbK or libusb0 as the driver.

Cause: Zadig’s target-driver dropdown defaults to whichever driver was used last — easy to leave on libusbK from a previous device. GopherTrunk’s transport speaks WinUSB only; libusbK/libusb-win32 are different APIs.

Fix: Re-run Zadig, change the target driver dropdown to WinUSB, and click Replace Driver.

USB Selective Suspend drops the dongle mid-bring-up

Symptom: First sdr list --probe after plug-in succeeds; subsequent runs report winusb: WinUsb_Initialize failed.

Cause: Windows 11’s USB Selective Suspend power policy puts idle USB ports to sleep aggressively; WinUSB doesn’t always wake them cleanly.

Fix: Control Panel → Hardware and Sound → Power Options → Change plan settings → Change advanced power settings → USB settingsUSB selective suspend setting → Disabled.

USB 3.0 / xHCI controller quirks

Symptom: sdr list --probe succeeds, but the daemon reports bulk timeouts under load (you see bulk timeout in the log; the IQ stream stalls or drops).

Cause: Several xHCI controllers (notably early ASMedia, some Intel-side firmware regressions in Win11 22H2+) misbehave with bulk-IN URBs of the size GopherTrunk submits.

Fix: Move the dongle to a USB 2.0 port (most desktops have a few), or interpose a powered USB 2.0 hub. If you must use USB 3.0, disable xHCI Hand-off in the BIOS and let the OS legacy driver own the port.

Antivirus blocks the driver install during Zadig

Symptom: Zadig hangs at the install dialog, or completes with a success message but sdr doctor shows no change.

Cause: McAfee, Norton, Bitdefender, and several enterprise EDRs flag Zadig’s catalog as suspicious and silently block the install.

Fix: Temporarily disable real-time protection (or add Zadig’s install folder to the exclusion list) for the duration of the driver swap.

Windows S mode forbids unsigned driver installers

Symptom: Zadig won’t launch at all; Windows refuses to run non-Microsoft Store binaries.

Cause: Windows 11 in S mode restricts installs to the Microsoft Store.

Fix: Settings → System → Activation → Switch out of S mode. This is one-way (you cannot return to S mode without reinstalling Windows). Once switched, install GopherTrunk and run Zadig normally.

Group Policy “Prevent installation of devices not described by other policy settings”

Symptom: Zadig prompts for elevation, completes successfully, but sdr doctor shows the driver unchanged.

Cause: Managed/AD-joined machines often deny driver installs for devices not pre-approved by IT.

Fix: Coordinate with IT to whitelist the dongle’s VID/PID (USB\VID_0BDA&PID_2838, etc.) under Administrative Templates → System → Device Installation → Device Installation Restrictions → Allow installation of devices that match any of these device IDs.

List audio output devices too — useful when you plan to enable live playback (§ 13):

gophertrunk audio list

5. Build your first config

The installer asked you for a data folder (default Documents\GopherTrunk), seeded a config.yaml in its config\ subfolder, and pinned the path in HKCU\Environment\GOPHERTRUNK_CONFIG so the daemon discovers it automatically. Open it from the Start Menu shortcut “Edit my config.yaml (Notepad)” or directly:

notepad "$env:USERPROFILE\Documents\GopherTrunk\config\config.yaml"

The seeded config uses config-relative pathsrecordings.dir: ../recordings, storage.path: ../data/calls.db, ../iq, ../logs — which the daemon resolves against the folder holding config.yaml. So recordings, the database, IQ captures, and logs land in the sibling folders under your data root with nothing to edit. Absolute paths and %GOPHERTRUNK_HOME%\... references still work if you want files elsewhere.

A read-only reference copy of the full annotated template stays at C:\Program Files\GopherTrunk\config.example.yaml (Start Menu → “Configuration template”).

The full schema reference is in § 11. The bare minimum to get a working scanner is:

  • sdr.devices[].serial — match the serial from sdr list.
  • trunking.systems[].name — display name for your trunked system.
  • trunking.systems[].protocolp25, dmr, nxdn, tetra, motorola, edacs, ltr, mpt1327, dpmr, dstar, or ysf.
  • trunking.systems[].control_channels — list of control-channel frequencies in Hz.
  • trunking.systems[].talkgroup_file — path to a Trunk-Recorder-style talkgroup CSV. Generate one with gophertrunk import-pdf (§ 10) or hand-author per import.md#csv-format.

Interactive wizard

First-time operators can skip hand-editing entirely:

gophertrunk import-pdf -wizard

The wizard asks one question per config section (log level, API bind, auth mode, CORS, storage, recordings, retention, SDR devices, scanner cockpit, audio playback) and writes a fully-annotated config.yaml. Defaults match config.example.yaml, so pressing Enter through every screen still produces a valid file.

Combine with a RadioReference import to bootstrap a region in one pass:

gophertrunk import-pdf -wizard -pdf maricopa.pdf

Full reference: import.md.

6. Run the daemon

gophertrunk run

The daemon walks $GOPHERTRUNK_CONFIG%APPDATA%\GopherTrunk%USERPROFILE%\Documents\GopherTrunk (each scanned at its top level and in its config\ subfolder) → .\config.yaml and loads the first one it finds, printing config: loaded <path> on startup so you can confirm the choice. If you keep multiple configs in the config\ folder (e.g. config.yaml plus a prod.yaml), the daemon prints a numbered menu and asks which to load — Enter alone picks #1. A non-interactive launch (Windows service, Scheduled Task) auto-selects the first match with a stderr warning instead of hanging.

Override discovery any time:

gophertrunk run -config "C:\path\to\other.yaml"

Logs stream to the terminal. Press Ctrl+C to stop cleanly — the daemon installs a signal.NotifyContext for SIGINT / SIGTERM, drains active calls (every ActiveCall gets a final CallEnd event so the call log captures it), and closes the database before exit.

Daemon flags:

Flag Description
-config <path> Path to config.yaml. Optional — when omitted the daemon walks $GOPHERTRUNK_CONFIG%APPDATA%\GopherTrunkDocuments\GopherTrunk (each at its top level and its config\ subfolder) → cwd and loads the first match (built-in defaults if nothing found).
-log-level <lvl> Override log.level (debug / info / warn / error).
-log-format <fmt> Override log.format (text / json).

The startup log includes a one-line patent-posture banner about AMBE+2 voice decoding (§ 18). Suppress with the environment variable GOPHERTRUNK_QUIET_BANNER=1 if it’s noise in your deployment.

7. Configure as a Windows service

For a long-running deployment, register GopherTrunk as a Windows service with NSSM — that’s the simplest path until a native service manifest ships.

# Download and extract nssm from https://nssm.cc
nssm install GopherTrunk "C:\Program Files\GopherTrunk\gophertrunk.exe" `
  run -config "%USERPROFILE%\Documents\GopherTrunk\config\config.yaml"
nssm set GopherTrunk AppDirectory "C:\Program Files\GopherTrunk"
nssm set GopherTrunk DisplayName "GopherTrunk Trunking Daemon"
nssm set GopherTrunk Start SERVICE_AUTO_START
nssm start GopherTrunk

Inspect the service:

Get-Service GopherTrunk
sc.exe query GopherTrunk

NSSM redirects stdout/stderr to its own log files; configure nssm set GopherTrunk AppStdout "C:\ProgramData\GopherTrunk\daemon.log" plus the matching AppStderr to point them somewhere persistent. Set nssm set GopherTrunk AppRotateFiles 1 to rotate the logs on restart.

To stop and remove later:

nssm stop GopherTrunk
nssm remove GopherTrunk confirm

8. Operate from the TUI

The Bubbletea-based terminal UI is built into the same binary as the daemon — no separate install. It connects to a running daemon over HTTP and renders eleven panels (Dashboard · Systems · Talkgroups · Active · History · Events · Tones · Metrics · Devices · Scanner · Settings).

Launch

In a second PowerShell window with the daemon running in the first:

gophertrunk tui

Default target is http://127.0.0.1:8080. Override with -server:

gophertrunk tui -server http://10.0.0.5:8080
gophertrunk tui -server https://radio.example.com -insecure

TUI flags:

Flag Default Purpose
-server URL http://127.0.0.1:8080 daemon base URL
-insecure false skip TLS certificate verification
-timeout DURATION 5s per-request timeout (SSE streams unaffected)
-no-color false strip ANSI colour
-write false surface mutation keybindings (requires api.auth.mode != disabled plus a token or loopback bypass on the daemon)

Keybindings — global

Key Action
Tab / Shift+Tab next / previous panel
1-9, 0 jump directly to a panel (0 = Scanner)
Ctrl+P open the fuzzy command palette
Ctrl+T toggle theme (dark ↔ monochrome)
? toggle help overlay
q / Ctrl+C quit

Keybindings — inside tables

Key Action
j / next row
k / previous row
g / G top / bottom
Page Down / Page Up scroll a page

Keybindings — panel-local

Panel Keys
Systems Enter open detail
Talkgroups / filter, s cycle sort, l toggle lockout, S toggle scan, + / - priority ± 1, Enter detail
Active calls e end highlighted call (write)
Call history r reload (no continuous poll)
Events / filter, p pause auto-scroll, c clear filter
Tone alerts R reset detector for highlighted device (write)
Metrics S run retention sweep now (write)
Scanner j / k move, h hold/resume, r force re-hunt, Enter dwell, L lockout, m cycle scan_mode, + / - / M / R volume±/mute/record, f manual VFO tune
Settings [ / ] cycle tabs

The TUI is mouse-aware: click a tab to switch panels, click a row to move the cursor, scroll-wheel up/down to advance rows.

Settings panel

A read-only inspector of the live daemon configuration, fetched once at startup and refreshed every 30 s from GET /api/v1/runtime. Cycle tabs (Daemon · Storage · Audio · Recording · Tones · API · Vocoders · SDR · FEC) with [ / ]. Every config knob the daemon reads has a touch-point here.

Mutations (write mode)

Mutation keybindings are hidden by default. To unlock them, both the daemon and the TUI must opt in:

# config.yaml (daemon side)
api:
  http_addr: "127.0.0.1:8080"
  auth:
    mode: "auto"        # loopback bypass; or `required` + token
# TUI side
gophertrunk tui --write

When a mutation requires confirmation, a centered modal opens — y / Enter to fire, n / Esc to cancel.

Full reference: tui.md.

9. Operate from the web console

GopherTrunk ships a full browser-based operator console (a standalone static SPA — pure HTML/CSS/JS, no Node.js, no embedded server in the daemon). Every TUI panel has a browser counterpart.

Launch on the same machine as the daemon

Open File Explorer to the web\ subfolder of your data root (default %USERPROFILE%\Documents\GopherTrunk\web) and double-click index.html. The Start Menu shortcut “Web operator console” points at the same file; “Signal Lab console” and “Config Builder console” open the web\siglab\ and web\configbuilder\ consoles alongside it. On the connect screen, enter:

  • Server URL: http://127.0.0.1:8080
  • Bearer token: the contents of api.auth.token_file (or empty if auth.mode: disabled)
  • Remember on this device: tick to store the token in localStorage instead of sessionStorage.

Operate from another device on the LAN

Canonical “headless box, operate from the couch” scenario.

  1. Edit config.yaml:

    api:
      http_addr: "0.0.0.0:8080"
      cors:
        allowed_origins:
          - "null"          # SPA opened via file:// on the laptop
      auth:
        mode: "required"
        token_file: "C:\\ProgramData\\GopherTrunk\\api-token"
    
  2. Restart the daemon.

  3. Copy your data root’s web\ folder to the operating device (USB stick, file share, or download the matching release archive on that device and use its gophertrunk-web/ directory).

  4. Double-click index.html, enter the daemon’s LAN URL and the bearer token on the connect screen.

Install as a PWA

The SPA is a Progressive Web App. After connecting once:

  • Desktop Edge / Chrome: click the install icon in the address bar (right side, small monitor glyph).
  • Android Chrome / Edge: an install banner appears once the service worker registers — accept it, or use the browser menu → “Install app”.
  • iOS Safari: Share button → “Add to Home Screen”.

The installed PWA still talks to whichever daemon URL you set on the connect screen.

Write mode in the browser

Mutation buttons (end-call, talkgroup edits, scanner controls, retention sweep, tone-detector reset) are hidden by default. Unlock them under Settings → Allow mutations from this browser. If the daemon reports allow_mutations: false, fix the api.auth.mode / trusted_networks config first (§ 15).

Audio playback

The audio cockpit lives at the top of the Dashboard. The browser streams live PCM from GET /api/v1/audio/stream — a continuous open-ended WAV body, no JavaScript decoding required.

iOS / Android browsers require a one-shot “Tap to enable audio” gesture per session because of autoplay rules. The SPA shows a prompt on first connect.

Full reference: web.md.

10. Import trunked systems from RadioReference

The import-pdf subcommand parses two source types and merges them into your config.yaml, generating Trunk-Recorder-style talkgroup CSVs as it goes:

  • RadioReference.com PDF exports — the Download menu near the top of any P25 trunking-system page (offers PDF / CSV / DSD).
  • RadioReference native CSV — the CSV option from the same Download menu. Flat talkgroup list; pair with -name and -sysid.
  • Structured CSV bundles — a single multi-section CSV file per system (format documented in import.md#csv-format).

Quick start — RadioReference PDF

  1. Sign in to RadioReference, open the trunking-system page (e.g. “Maricopa County”), click Download → PDF in the page header, save the file. (URL pattern: https://www.radioreference.com/db/sid/<sid>/download.)
  2. Run:

    gophertrunk import-pdf `
      -pdf maricopa.pdf `
      -config "$HOME\gophertrunk.yaml"
    
  3. The review TUI launches. Toggle Include on each site, edit Scan / Lockout / Priority on talkgroups, press w to write or q to discard.

Quick start — CSV bundle

gophertrunk import-pdf `
  -csv my-system.csv `
  -config "$HOME\gophertrunk.yaml"

Review TUI keybindings

View Key Action
Any w Write merged config + CSVs and exit
Any q / Ctrl+C Quit without writing
Systems list / Move cursor
Systems list Enter Open system
System (Sites tab) Space Toggle site Include flag
System (any tab) Tab Switch Sites ↔ Talkgroups
Talkgroups s Toggle Scan
Talkgroups L Toggle Lockout
Talkgroups 0-9 Set Priority (0 clears)
Talkgroups e Edit Alpha Tag
System view Esc / h Back to systems list

Import flags

Flag Description
-pdf <file> RadioReference PDF (repeatable).
-csv <file> Structured CSV bundle (repeatable).
-config <path> Existing config.yaml, merged in place. Default ./config.yaml.
-csv-dir <dir> Where to write talkgroup CSVs. Default: directory of -config.
-no-tui Skip the review TUI; merge from parsed defaults.
-dry-run Print planned changes and exit without writing.
-force Overwrite an existing trunking.systems[] entry with the same name.
-wizard Launch the interactive config-builder wizard.

Writes are atomic: each CSV and the config are written to a temp file in the destination directory and renamed into place after both struct-level and node-level YAML schema validations pass. Comments and unrelated blocks in your existing config.yaml are preserved verbatim.

Full reference: import.md.

11. Configuration reference (config.yaml)

Every section of the daemon config, mapped to the schema in config.example.yaml. Defaults are what you get when the key is omitted entirely.

log

log:
  level: info       # debug | info | warn | error
  format: text      # text | json

text is human-readable; json is structured for ingestion into log aggregators.

api

api:
  http_addr: "127.0.0.1:8080"    # HTTP REST + SSE + WebSocket
  grpc_addr: "127.0.0.1:50051"   # gRPC
  allow_mutations: false         # legacy gate; prefer auth.mode
  auth:
    mode: "auto"                 # auto | required | disabled
    # token: "inline-token-here"
    # token_file: "C:\\ProgramData\\GopherTrunk\\api-token"
    # trusted_networks:
    #   - "10.0.0.0/8"
    #   - "192.168.0.0/16"
  cors:
    allowed_origins: []
  tls_cert: ""                   # PEM cert path; pair with tls_key
  tls_key: ""                    # PEM key path

See § 15 for the full authentication policy discussion.

metrics

metrics:
  enabled: true     # mounts /metrics on the HTTP API

See § 16 for the Prometheus series exposed.

storage

storage:
  path: "C:\\ProgramData\\GopherTrunk\\calls.db"
  cc_cache_file: "C:\\ProgramData\\GopherTrunk\\cc-cache.json"

Use forward slashes or escape backslashes in YAML strings. The SQLite database holds the call-log history (queried by the TUI History panel and the web History tab). The CC cache file persists last-known control-channel frequencies across restarts so the hunter can lock faster on next boot.

recordings

recordings:
  dir: "C:\\ProgramData\\GopherTrunk\\recordings"
  sample_rate: 8000
  write_raw: true       # also append a .raw sidecar with vocoder frames
  equalizer:
    enabled: false      # CMA blind equalizer (simulcast mitigation)
    taps: 8
    step_size: 0.0001

Per-call recordings land under <dir>\<system>\<talkgroup>\<UTC>_src<id>.wav. With write_raw: true, a sibling <UTC>_src<id>.raw carries the per-frame compressed vocoder stream — 11 bytes/frame for IMBE, 7 bytes/frame for AMBE+2 — for offline decoding through DSD-FME, OP25, or gophertrunk decode (§ 18).

The CMA equalizer is opt-in because simulcast mitigation costs CPU and may distort clean-RF capture. Operators not on a simulcast site shouldn’t enable it.

retention

retention:
  call_log_days: 30   # 0 disables call-log row sweep
  files_days: 14      # 0 disables filesystem sweep
  interval: "1h"      # how often the sweeper runs

The sweeper deletes rows from calls.db older than call_log_days and recording files older than files_days. Trigger a sweep on demand from the TUI Metrics panel (S) or via POST /api/v1/retention/sweep.

sdr

sdr:
  sample_rate: 2_400_000
  devices:
    - serial: "00000001"   # match `gophertrunk sdr list`
      role: control        # control | voice | auto
      ppm: 0               # 0 is fine for TCXO-equipped units
      gain: "auto"         # "auto" or tenths-of-dB ("496" = 49.6 dB)
      bias_tee: false      # enable 5V bias-tee for external LNA
    - serial: "00000002"
      role: voice
      ppm: 0
      gain: "auto"
      bias_tee: false
  • Roles. control dongles dwell on a system’s control channel and decode signalling. voice dongles follow grants and decode voice payloads. auto lets the pool assign on first attach.
  • PPM. Reference-clock offset in parts per million. TCXO units (NESDR Smart v5, RTL-SDR Blog v3+) measure within ±0.5 ppm out of the box; plain DVB-T sticks usually need a calibration value somewhere in ±20 ppm.
  • Gain. "auto" uses the tuner’s AGC. A numeric string is the tuner gain in tenths of a dB (e.g. "496" = 49.6 dB). The supported values per tuner are listed in gophertrunk sdr list under “gains(0.1 dB)”.
  • Bias-tee. Powers an external LNA via the SMA. Only enable on dongles that ship with the bias-tee circuit (NESDR Smart v5, RTL-SDR Blog v3+ — see hardware.md).

trunking

trunking:
  systems:
    - name: "Example-P25"
      protocol: p25
      control_channels:
        - 851_000_000
        - 852_000_000
      talkgroup_file: "C:\\ProgramData\\GopherTrunk\\talkgroups-p25.csv"

Supported protocols: p25 (Phase 1 + Phase 2 share the parent key — Phase 2 is selected by setting p25_phase2_* opt-ins), dmr, nxdn, tetra, motorola (Type II), edacs, ltr, mpt1327, dpmr, dstar, ysf.

FEC opt-outs

Every protocol’s forward-error-correction chain is on by default. Operators feeding pre-stripped capture files (DSD-FME -r dumps, OP25 fixtures, MMDVMHost / DSDcc test data) opt out per-system:

Protocol YAML key Opt-out value
TETRA tetra_channel_coding off
LTR FCS ltr_fcs_mode off
LTR Manchester ltr_manchester_mode off / nrz
P25 Phase 2 trellis p25_phase2_trellis_mode off
P25 Phase 2 RS p25_phase2_rs_mode on enables verification
P25 Phase 2 PN44 scrambler p25_phase2_scrambler_mode on / probe
NXDN Viterbi nxdn_viterbi_mode off
EDACS BCH edacs_bch_mode off
MPT 1327 BCH mpt1327_bch_mode off
MPT 1327 CWSC tolerance mpt1327_cwsc_tolerance 0 / exact / off, or 0-15 for custom
Motorola Type II BCH motorola_bch_mode off
D-STAR FEC dstar_fec_mode on enables the JARL DV-mode chain

TETRA additionally requires tetra_colour_code: <non-zero> for non-BSCH channels — descrambling produces garbage without it.

Receiver clock recovery

P25 Phase 2 and TETRA route through the Gardner symbol-timing- recovery loop by default. Operators with sample-aligned synthesized IQ fixtures can opt back to the naive sps-th-sample decimator:

Receiver YAML key Opt-out
P25 Phase 2 p25_phase2_clock_mode naive / off
TETRA tetra_clock_mode naive / off

Full reference: opt-in-features.md.

scanner

See § 12 for the full scanner subsystem reference.

audio

See § 13 for the live-playback config.

tone_out

See § 14 for the paging-tone detector config.

12. Scanner subsystems

GopherTrunk runs three scanner subsystems on top of the trunking engine: the engine-level scan-mode gate, the multi-system control- channel hunter, and the conventional FM scan list.

scanner:
  scan_mode: all       # all | list
  cc_hunt:
    enabled: true
    dwell_ms: 3000
    backoff_ms: 5000
    max_backoff_ms: 60000
  manual_tune_enabled: false
  manual_tune_disabled: false
  conventional: []

scan_mode

  • all (default) — follow every non-locked-out grant. The backwards-compatible behaviour.
  • list — follow only grants whose talkgroup carries Scan: true in its CSV row. Emergency grants bypass the gate regardless.

Cycle at runtime from the TUI Scanner panel (m) or via PATCH /api/v1/scanner body {"scan_mode":"list"}.

cc_hunt

Multi-system control-channel hunter. When enabled, the daemon rotates a free control SDR through every configured system’s control channels until each system locks. Per-system hold / resume / force-retune from the TUI Scanner panel:

  • h hold or resume the highlighted system
  • r force re-hunt (confirms)

Conventional FM scan list

A fixed-frequency analog FM scanner. Requires a dedicated Voice SDR — the last Voice device in the pool is used.

scanner:
  conventional:
    - label: "Sheriff Repeater"
      frequency_hz: 155895000
      mode: fm
      squelch_dbfs: -48
      hangtime_ms: 1500
      priority: 4
      tone:
        mode: ctcss        # ctcss | dcs | none
        ctcss_hz: 100.0
        # dcs_code: "023"  # 3-digit octal for DCS

Per-channel knobs:

Knob Description
label Display name
frequency_hz Tuner centre frequency
mode fm (default), nfm, am; protocols extend over time
squelch_dbfs IQ-power squelch threshold in dBFS
hangtime_ms Carrier-lost dwell before hopping
priority Integer 0–10 for scan order
tone Optional CTCSS / DCS sub-audible squelch gate

With tone configured, the scanner only opens when both the carrier is present and the configured tone is detected, so adjacent-system traffic on the same frequency doesn’t trigger a false dwell. Omit the block (or set mode: none) for plain carrier-only squelch.

Manual VFO tune

Press f on the Scanner panel to enter a frequency in MHz and listen immediately (a runtime VFO channel is added and the scanner dwells). The TUI surfaces the input when:

  • Auto-detect. ≥ 2 Voice SDRs are present in the pool — the daemon constructs the conventional scanner off the spare.
  • Forced. manual_tune_enabled: true constructs the scanner even with a single Voice SDR (steals it from the trunking pool).
  • Vetoed. manual_tune_disabled: true overrides the auto-detect.

Web console exposes the same control under the Scanner tab.

13. Audio playback and recordings

Live audio playback routes decoded PCM to your Windows sound device. Disabled by default so headless / service deployments stay silent; WAV recording is unaffected.

audio:
  enabled: false       # set true to play decoded calls live
  device: ""           # empty = system default; "null" forces no-op
  sample_rate: 8000    # must match recordings.sample_rate
  buffer_ms: 80        # playback queue depth; higher = more jitter-tolerant
  volume: 0.8          # 0..1 software gain
  muted: false

List the available output devices first:

gophertrunk audio list

Set device to one of the listed names to pin output, or leave empty for the system default sink. The Windows backend uses WASAPI; failure to initialise (no sound device, exclusive-mode contention) falls back to a silent player automatically so the daemon stays running.

Recordings always land on disk per § 11 (recordings.dir). The .raw sidecar (per-call vocoder frames) makes it easy to re-decode post hoc through DSD-FME / OP25 or gophertrunk decode (§ 18).

Mutate audio at runtime from the TUI Scanner panel:

Key Effect
+ / - Volume ± 5%
M Mute toggle
R Recording toggle

The web console exposes the same controls on the Dashboard audio cockpit. Live PCM streams to the browser via GET /api/v1/audio/stream — a continuous open-ended WAV body.

14. Tone-out paging-tone alerts

GopherTrunk fires tone.alert events when configured paging tones are detected on a Voice device’s PCM stream — the most common use case is two-tone sequential (Motorola Quick Call II) fire/EMS dispatch.

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"

Per-profile schema:

Key Description
name Required, unique within profiles
alpha_tag Human-readable label (UI / webhook / log)
tones[] Ordered tone list, ≥ 1 entry
tones[].frequency_hz Target tone in Hz
tones[].min_duration Required dwell before counting
tones[].max_duration Caps the on-time; 0 = no upper bound
tolerance_hz Frequency drift tolerance (default 15 Hz)
magnitude_threshold Goertzel-magnitude floor (default 0.05)
max_gap Silence allowed between tones (default 200ms)
cooldown Re-fire suppression window (default 30s)
system Restrict to one trunked system (empty = all)
group_id Restrict to one talkgroup (0 = all)

Multiple profiles coexist; the detector fires the first one whose tone sequence matches. The bundled config.example.yaml includes commented-out single-tone, system-scoped, and tight-tolerance examples.

View live tone alerts in the TUI Tones panel or the web console Tones tab. Reset the detector for a device (R in the TUI) when you want to re-arm after a false positive.

15. API authentication and hardening

Every HTTP mutation endpoint (end-call, talkgroup priority / lockout / scan, retention sweep, tone-detector reset, scanner cockpit, audio cockpit, manual tune) is gated by api.auth.

Policy modes

  • auto (default) — require a bearer token on non-loopback binds; bypass the check on loopback (127.0.0.1 / ::1). Reasonable for single-host operator boxes — kernel-enforced reachability is a peer-cred proxy. The daemon refuses to start in auto mode on a public bind without a configured token.
  • required — every mutation request must carry a valid Bearer token, even from loopback. Use when the daemon shares a host with untrusted users.
  • disabled — wide-open mutations, no auth. Equivalent to the legacy allow_mutations: true. Only safe behind an external proxy that does its own auth.

Generating a token

# 32 bytes of random, hex-encoded — 64 ASCII chars.
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
([System.BitConverter]::ToString($bytes) -replace '-', '').ToLower() `
  | Out-File -Encoding ascii "C:\ProgramData\GopherTrunk\api-token"

Then in config.yaml:

api:
  http_addr: "0.0.0.0:8080"
  auth:
    mode: "required"
    token_file: "C:\\ProgramData\\GopherTrunk\\api-token"

The daemon re-reads token_file on every mutation request, so rotation is a one-step file overwrite — no restart, no SIGHUP.

Client usage

$token = Get-Content "C:\ProgramData\GopherTrunk\api-token"
curl.exe -sS http://daemon:8080/api/v1/mutations
curl.exe -sS -X POST `
  -H "Authorization: Bearer $token" `
  -H "Content-Type: application/json" `
  -d '{"reason":"manual"}' `
  http://daemon:8080/api/v1/calls/00000001/end

GET /api/v1/mutations is always open and reports auth_mode + can_mutate for the current request, so TUIs / scripts can light up write-side keybindings without probing real endpoints.

Trusted networks (LAN bypass)

For LAN deployments where you trust the whole segment:

api:
  http_addr: "192.168.1.10:8080"
  auth:
    mode: "auto"
    trusted_networks:
      - "192.168.0.0/16"

The middleware honours RemoteAddr only — X-Forwarded-For is intentionally ignored so the bypass isn’t forgeable by a hostile upstream proxy.

CORS (browser access)

api:
  cors:
    allowed_origins:
      - "null"                          # SPA opened via file://
      - "http://laptop.local:8000"      # static-host alternative
      # - "*"                           # any origin (loopback only)

"null" covers the canonical case where the web console is opened via file:// from File Explorer. The browser sends the literal string null as the Origin for those loads.

TLS

api:
  http_addr: ":8080"
  grpc_addr: ":50051"
  tls_cert: "C:\\ProgramData\\GopherTrunk\\tls\\cert.pem"
  tls_key:  "C:\\ProgramData\\GopherTrunk\\tls\\key.pem"

Both keys must be set together — setting one without the other is a config error the daemon refuses to start with. The same cert / key pair is used for both the HTTP and gRPC listeners. Cert rotation requires a daemon restart.

Generate a test cert with OpenSSL for Windows or via WSL:

openssl req -x509 -newkey rsa:2048 -nodes -days 365 `
  -keyout C:\ProgramData\GopherTrunk\tls\key.pem `
  -out    C:\ProgramData\GopherTrunk\tls\cert.pem `
  -subj "/CN=gophertrunk.example.com"

Full reference: hardening.md.

16. Metrics and health endpoints

GET /metrics

Prometheus exposition (when metrics.enabled: true):

Series Type Description
gophertrunk_events_total{kind=...} counter Every event observed on the internal bus
gophertrunk_calls_started_total{system,protocol,encrypted} counter Calls started, by system/protocol/encryption
gophertrunk_calls_total{system,protocol,encrypted,reason} counter Calls completed, by system/protocol/encryption + EndReason
gophertrunk_calls_active{system,protocol} gauge Active calls per system+protocol (use sum() for total)
gophertrunk_control_channel_locked{system=...} gauge 1 while CC locked
gophertrunk_control_channel_frequency_hz{system=...} gauge Locked CC frequency in Hz; series deleted on loss
gophertrunk_control_channel_transitions_total{system,event} counter CC lock/lost transitions
gophertrunk_sdr_attached{driver,serial,role} gauge 1 per attached SDR (event-driven)
gophertrunk_sdr_gain_db{driver,serial,role} gauge Configured gain in dB; NaN under AGC
gophertrunk_sdr_gain_auto{driver,serial,role} gauge 1 when tuner is running AGC
gophertrunk_sdr_ppm{driver,serial,role} gauge Configured PPM correction
gophertrunk_sdr_bias_tee{driver,serial,role} gauge 1 when bias-tee is enabled
gophertrunk_sdr_iq_underruns_total{driver,serial} counter IQ pipeline drops
gophertrunk_sdr_iq_power_dbfs{system} gauge Mean control-SDR IQ power, ≈ 1 s window (idle ≈ -45, healthy ≈ -25, clip > -3)
gophertrunk_sdr_usb_reconnects_total{driver,serial} counter USB re-opens
gophertrunk_decode_errors_total{protocol,stage} counter Decode failures
gophertrunk_build_info{version} gauge Always 1; build version label

GET /api/v1/health

Always open (no token required) so liveness / readiness probes can hit it from outside the auth boundary:

{
  "status":              "ok",
  "now":                 "2026-05-13T19:00:00Z",
  "version":             "v1.2.3",
  "pool_attached_count": 2,
  "active_calls":        1,
  "db_connected":        true,
  "metrics_enabled":     true,
  "auth_mode":           "auto"
}

Probe semantics:

Probe type Condition
Liveness HTTP 200 + body decodes
Readiness status == "ok" AND pool_attached_count >= 1 AND db_connected == true

17. CLI reference

gophertrunk [run] [-config path]    run the daemon (default)
gophertrunk sdr list                list discovered SDR devices
gophertrunk audio list              list audio output devices
gophertrunk tui [-server URL]       open the operator TUI
gophertrunk decode [flags]          decode a captured .raw frame stream into a WAV
gophertrunk import-pdf [flags]      import a RadioReference PDF / CSV bundle
gophertrunk version                 print build version + git SHA + build time
gophertrunk help                    show usage

decode

Run the registered in-binary vocoders against a .raw frame stream out-of-band:

gophertrunk decode -in call.raw  -out call.wav  -vocoder imbe
gophertrunk decode -in dmr.raw   -out dmr.wav   -vocoder ambe2
gophertrunk decode -list-vocoders        # enumerate registered names

Stdin / stdout work via -in -, so capture pipelines stream into the decoder without a temporary file. See § 18 for the supported vocoders.

18. Vocoders and voice calibration

GopherTrunk ships pure-Go IMBE (P25 Phase 1) and AMBE+2 (P25 Phase 2, DMR Tier II/III, NXDN, dPMR, D-STAR voice) decoders. Both are on by default; AMBE+2 is patent-encumbered in some jurisdictions (DVSI IPR portfolio), and the legal responsibility for operating it falls on the deployer — see vocoders.md §”Patent posture”.

Backend Build tag Default? Notes
null (silence) none yes Always available
imbe (pure-Go) none yes P25 Phase 1 LDU1 / LDU2
ambe2 (pure-Go) none yes P25 Phase 2 / DMR / NXDN / dPMR / D-STAR
dvsi (USB-3000 chip) -tags dvsi no Wire protocol shipping; USB transport stub

Live-pipeline auto-decode maps Grant.Protocol to a vocoder per the default mapping; override with RecorderOptions.VocoderForProtocol if you’re embedding the recorder.

Voice calibration

To tune the in-tree decoders’ loudness against DSD-FME / OP25 reference output, follow the recipe in voice-calibration.md:

  1. Record a reference call with recordings.write_raw: true.
  2. Decode the .raw through DSD-FME / OP25 to get a reference WAV.
  3. Run cmd/voice-calibrate (or the in-tree internal/voice/calibrate unit test) to compute RMS-ratio (dB) and best-alignment cross-correlation.
  4. Tune internal/voice/mbe/agc.go::TargetPeak if the RMS-ratio is outside ±3 dB.

Acceptance: |RMSRatioDb| < 3.0 and PeakXcorr > 0.85.

19. Upgrading and uninstalling

Upgrade

Run a newer installer in place — it overwrites C:\Program Files\GopherTrunk\gophertrunk.exe and refreshes the Start Menu entries. Your config.yaml, recordings, and call-log DB (wherever you wrote them) are left alone. If gophertrunk is running as an NSSM service, stop it first:

nssm stop GopherTrunk
.\gophertrunk-<version>-windows-amd64-setup.exe
nssm start GopherTrunk

After upgrade, confirm the new build:

gophertrunk version

Uninstall

Settings → Apps → Installed apps → GopherTrunk → Uninstall. The uninstaller removes the install folder, every Start Menu entry, undoes the PATH change if you opted in, and clears the GOPHERTRUNK_CONFIG / GOPHERTRUNK_HOME env vars.

It then asks whether to also delete the Setup-managed parts of your data folder — the config, data, logs, and web subfolders (default No). Your capturesrecordings, iq, and exports — are always kept so an uninstall never destroys recordings. Delete the whole data folder by hand if you want a clean slate:

Remove-Item -Recurse "$env:USERPROFILE\Documents\GopherTrunk"

If you registered an NSSM service, remove it before uninstall:

nssm stop GopherTrunk
nssm remove GopherTrunk confirm

20. Troubleshooting

Symptom Likely cause + fix
gophertrunk not recognised in PowerShell PATH wasn’t added during install — open a fresh terminal, or run from C:\Program Files\GopherTrunk directly.
sdr list prints no SDR devices found Zadig WinUSB swap didn’t take. Re-run Zadig with Options → List All Devices and verify the Driver column shows WinUSB.
usb: device disconnected mid-stream The DVB driver re-attached itself, or Windows USB selective-suspend kicked in. Re-run Zadig; in Device Manager, disable “Allow the computer to turn off this device” under the USB hub’s Power Management tab.
WinUsb_Initialize fails The dongle is bound to the wrong driver — re-run Zadig and pick WinUSB.
SmartScreen blocks the installer Right-click → Properties → Unblock, or More info → Run anyway.
Audio plays as silence audio.enabled: false by default — set true in config. Confirm the device name in gophertrunk audio list.
daemon unreachable in the TUI Daemon isn’t running, or the -server URL points at the wrong host / port. Run curl.exe http://127.0.0.1:8080/api/v1/health to confirm.
Web console connect screen shows “Failed to fetch” Daemon not reachable. Confirm gophertrunk run is up and the URL is right.
Browser console shows “CORS preflight rejected” Daemon hasn’t allow-listed your SPA’s origin. Add it under api.cors.allowed_origins and restart.
401 on every web-console request Wrong bearer token. Re-check api.auth.token_file contents and re-enter on the connect screen.
Mutation buttons invisible in the web UI Write mode is off or the daemon rejects mutations. Settings → tick “Allow mutations”.
iOS / Android audio stops after 2 seconds Autoplay block. Tap the “Tap to enable audio” prompt on the Dashboard.
NSSM service exits immediately Stdout/stderr aren’t redirected — nssm set GopherTrunk AppStdout C:\ProgramData\GopherTrunk\daemon.log and re-check the log.
TUI event stream toast keeps reappearing SSE stream dropping. Check the daemon log; corporate proxies sometimes truncate SSE.

For anything else, open an issue at https://github.com/MattCheramie/GopherTrunk/issues with:

  • gophertrunk version output
  • the first ~50 lines of the daemon log
  • gophertrunk sdr list output
  • relevant excerpts of your config.yaml

See also