Skip to the content.

GopherTrunk logo

GopherTrunk 📻🐹

A low-latency digital-trunking scanner engine in Go. Pure-Go, zero CGO, single static binary.

GopherTrunk manages a pool of RTL-SDR dongles, runs a custom Go DSP pipeline, decodes the signalling layers of every major trunked-radio family (P25 Phase 1 / Phase 2, DMR Tier II / III, NXDN, Motorola Type II / SmartZone, EDACS / GE-Marc, LTR, MPT 1327, dPMR Mode 3, TETRA TMO) plus the D-STAR + Yaesu System Fusion amateur modes, follows voice grants by talkgroup priority, decodes the voice payload through pure-Go IMBE / AMBE+2 vocoders, writes per-call WAVs + raw-frame sidecars to disk, logs everything to SQLite, and streams metadata + live PCM to any frontend over gRPC, HTTP/SSE, or WebSocket. Live audio playback to the host’s speakers + a Bubbletea TUI cockpit ship alongside the headless daemon. Optional TLS, bearer-token auth on mutations, and a Prometheus /metrics surface make it deployable on shared infrastructure.

Support the project

GopherTrunk is developed in the open and powered entirely by community support. If it’s useful to you, please consider chipping in to keep the work going:

See docs/support.md for the full pitch and other ways to help out.

Features

Area Component
Hardware Pure-Go RTL-SDR driver (USBDEVFS / WinUSB transport + RTL2832U register layer + R820T/R820T2/R828D/E4000/FC0012/FC0013/FC2580 tuner drivers; CGO_ENABLED=0 everywhere, no librtlsdr / libusb build dependency), multi-device pool, role assignment, per-device gain (auto / tenths-of-dB) + PPM + bias-tee (5 V LNA power, e.g. NESDR Smart v5) applied at open time, DC blocker, IQ-imbalance correction, file-backed IQ replay (mock)
DSP Polyphase channelizer, FIR + Kaiser LPF designer + RRC + Gaussian-pulse premod designer, CIC, halfband, IQ + audio AGC (attack/release envelope follower for voice), L/M polyphase resampler (complex IQ + real audio), FM / C4FM / GFSK / FFSK (audio-band 1200-baud, MPT 1327) / DQPSK / π/4-DQPSK (configurable rotation; π/4 = TETRA, π/8 = P25 Phase 2 H-DQPSK) demods, single-pole IIR de-emphasis (75/50µs), Mueller-Müller clock recovery, frame-sync correlator
FEC primitives CRC-CCITT/FALSE + CRC-CCITT/XMODEM (callable init), CRC-6 (NXDN SACCH), Hamming(15,11,3), Hamming(13,9,3), Hamming(20,8) (DMR slot-type, t=3), extended Golay(24,12,8) + non-extended Golay(23,12,7) (P25 IMBE), BCH(63,16,11), BPTC(196,96), Reed-Solomon(12,9,4) over GF(2^8) with DMR Voice LC Header / Terminator / Embedded LC seeds, 4-state ½-rate Viterbi, 16-state K=5 ½-rate Viterbi (shared by NXDN SACCH and YSF FICH) with depuncture-marker support
P25 Phase 1 48-bit FSW + sync detector, NID parser (NAC + DUID) with BCH(63,16,11) error correction + even-parity check, full TSBK channel decode (TIA-102.BAAA Annex A 4-state ½-rate trellis + 98-dibit block deinterleaver) → CRC trailer validation, payload parsers for GroupVoiceChannelGrant / Update / NetworkStatus / RFSSStatus, IdentifierUpdate band-plan resolver, control-channel state machine emitting protocol = "p25" grants and decode.error events with nid-bch / tsbk-trellis / tsbk-crc / no-bandplan stages
P25 Phase 2 Outbound + inbound 20-dibit sync, 360 ms / 12-subframe superframe + SlotType enum, MAC PDU parser + opcode enum, GroupVoiceChannelGrant accessor, IQ → H-DQPSK dibit receiver (internal/radio/p25/phase2/receiver) composing the demod.PiOver4DQPSK helper with π/8 rotation + Gardner symbol-timing recovery (default on; p25_phase2_clock_mode: naive for synthesized fixtures) at 6000 sym/s, control-channel state machine emitting protocol = "p25-phase2" grants; full TIA-102 chain (4-state ½-rate trellis + RS(24, 16, 9) outer verifier + PN44 LFSR scrambler with per-burst slot-offset blind probe + NSB-driven runtime seed installation) all default-on
DMR (Tier III) All 9 ETSI sync patterns, burst layout (132 dibits), Color Code + Data Type via (20,8,7) shortened-Hamming slot-type FEC (corrects up to 3 bit errors per slot type), CSBK with CRC, payload parsers for TalkGroup/Private Voice grants (LCN + timeslot) + Aloha + AdjacentSiteStatus + SystemInfoBroadcast, LCN → Hz band-plan resolver (linear + table forms), IQ → C4FM dibit receiver (internal/radio/dmr/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer to fan dmr.DibitSink out to a future ControlChannel.Process adapter, control-channel state machine emitting protocol = "dmr-tier3" grants and decode.error events with no-bandplan stage
DMR (Tier II) Shares the burst / slot-type / BPTC(196,96) layers with Tier III; adds a 72-bit Full Link Control parser (FLCO enum: GroupVoiceChannelUser / UnitToUnitVoice / TalkerAlias / GPS / Terminator) with RS(12,9,4) parity verification (Voice LC Header seed) and a per-repeater conventional-mode state machine that decodes Voice LC Header bursts and emits protocol = "dmr-tier2" grants on the bus (deduped per call, cleared on Terminator-with-LC) and decode.error events with voiceheader-bptc / voiceheader-rs stages. IQ → C4FM dibit chain (same internal/radio/dmr/receiver Tier III uses) feeds tier2.ConventionalChannel.Process (multi-pattern sync detect across all 9 ETSI sync words → 132-dibit burst slice → slot-type Hamming(20,8) decode → IngestBurst → BPTC(196,96) → RS(12,9) → grant publication). DMR Tier II is conventional (per-repeater) rather than trunked, but the ccdecoder pipeline factory (newDMRTier2Pipeline) routes it through the same engine + recorder + composer surfaces as the trunked protocols — the state machine emits cc.locked on first valid burst so the supervisor’s lock model still works.
NXDN 192-dibit frame layout (4800 BFSK / 9600 4-FSK), LICH parse with parity + 16-bit doubled-wire decoder, FSW correlator, full SACCH channel decode (K=5 ½-rate convolutional Viterbi + 60-position sub-frame deinterleaver + 12-bit puncture undo + CRC-6 trailer), CAC parser with CRC, RCCH opcode enum + payload parsers, IQ → C4FM dibit receiver (internal/radio/nxdn/receiver) for the 9600-baud 4-FSK variant composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer to fan nxdn.DibitSink out to a future ControlChannel.Process adapter (BFSK variant — 2-level slicer — is a follow-up), control-channel state machine
Motorola Type II OSW parser, opcode constants, LCN → Hz band-plan resolver (linear + table), IQ → MSK bit receiver (internal/radio/motorola/receiver) composing FM demod + Gaussian matched filter (BT = 0.5 approximation of MSK matched filter) + Mueller-Müller clock recovery at 3600 baud + 2-level slicer to fan motorola.BitSink out to a future ControlChannel.Process adapter, control-channel state machine emitting protocol = "motorola" grants
EDACS / GE-Marc 40-bit CCW parser, command enum (Idle / GroupVoiceGrant / ProVoiceGrant / IndividualCall / DataGrant / SystemID / AdjacentSite / Emergency / Affiliation / Encryption), per-command accessors with encrypted / emergency flags, LCN → Hz resolver, IQ → GFSK bit receiver (internal/radio/edacs/receiver) composing FM demod + Gaussian matched filter (BT = 0.3) + Mueller-Müller clock recovery + 2-level slicer at 9600 baud to fan edacs.BitSink out to a future ControlChannel.Process adapter, control-channel state machine emitting protocol = "edacs" grants
LTR 41-bit per-repeater Status word parser, Channel → Hz resolver, optional area filter, IQ → sub-audible bit receiver (internal/radio/ltr/receiver) composing FM demod + narrow sub-audible LPF (~300 Hz Kaiser-windowed FIR) + Mueller-Müller clock recovery at 300 baud + 2-level slicer to fan ltr.BitSink out to a future ControlChannel.Process adapter (Manchester decode + 41-bit framing live there), per-repeater state machine emitting protocol = "ltr" grants when a status indicates an active call
MPT 1327 64-bit address-codeword parser (38 info + 26 BCH parity consumed upstream), CodewordKind enum (ALH / AHY / AHYC / GTC / ACK / Disconnect / Data / Emergency), accessors for GTC voice grants + AHYC system broadcast, channel resolver, IQ → FFSK bit receiver (internal/radio/mpt1327/receiver) composing FM demod + FFSK tone discriminator (mark = 1200 Hz / space = 1800 Hz CCIR FFSK) + Mueller-Müller clock recovery at 1200 baud to fan mpt1327.BitSink out to a future ControlChannel.Process adapter, control-channel state machine emitting protocol = "mpt1327" grants
dPMR (Mode 3) FS1 / FS2 / FS3 24-dibit sync, 80-bit CSBK parser, MessageType enum (RegistrationRequest / Response, VoiceServiceAllocation, IndividualVoiceAllocation, DataServiceAllocation, ServiceRequest, StandingServiceStatus, Release, Idle), AsVoiceGrant + AsSiteBroadcast accessors, PMR446 default band-plan, IQ → C4FM dibit receiver (internal/radio/dpmr/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer at the 2400-sym/s rate to fan dpmr.DibitSink out to a future ControlChannel.Process adapter, control-channel state machine emitting protocol = "dpmr" grants
TETRA (TMO) Normal + extended training-sequence sync, generic Layer-3 PDU parser (4-bit Discriminator + type + payload), CMCE D-CONNECT / D-TX-GRANTED / D-RELEASE accessors, MLE-SYSINFO accessor (MCC / MNC / Location Area), TETRA-380 / 410 / 800 carrier resolver, IQ → π/4-DQPSK dibit receiver (internal/radio/tetra/receiver) composing the demod.PiOver4DQPSK helper with π/4 rotation + α = 0.35 RRC + Gardner symbol-timing recovery (default on; tetra_clock_mode: naive for synthesized fixtures) at 18000 sym/s, full ETSI EN 300 392-2 §8.3.1 channel-coding chain (descramble + deinterleave + depuncture + K=5 Viterbi + CRC-16) default-on via tetra_channel_coding: on, control-channel state machine emitting protocol = "tetra" grants
D-STAR 24-bit JARL Header Frame Sync (0xEAA060) + 24-bit Slow Data Sync, 41-byte PCH header parser (FLAG1 + RPT2 / RPT1 / UR / MY1 / MY2 + CRC-CCITT), IsGroupCall / IsEmergency / IsData accessors, IQ → GMSK bit receiver (internal/radio/dstar/receiver) composing FM demod + Gaussian matched filter (BT = 0.5) + Mueller-Müller clock recovery + 2-level slicer at 4800 baud to fan dstar.BitSink into ControlChannel.Process (24-bit Frame Sync detector with tolerance=2 → PCH window → CRC-CCITT verify → Header → Ingest), full JARL DV-mode FEC chain (internal/radio/framing/dstar_header.go: K=5 R=1/2 convolutional encoder + ViterbiK5 decoder + PN15 LFSR scrambler + 22×30 block interleaver, 660 on-wire bits → 328 info bits, gated by SetFECMode(FECOn) and dstar_fec_mode: on), repeater state machine emitting protocol = "dstar" grants on group transmissions. The full chain recovers single-bit channel errors through the Viterbi inner code; calibration against an MMDVMHost-encoded real-air capture (to confirm the interleaver column order matches the wire format) lands once captured data is available.
YSF (Yaesu System Fusion) 4800-baud C4FM, 480-dibit / 100 ms frame layout (FSW / FICH / DCH offsets), 40-bit FSW correlator with mismatch tolerance, 32-bit Frame Information Channel parser (FrameType / CallType / Frame Number / Frame Total / DataType / VoIP / Squelch fields) with CRC-16 trailer, K=5 ½-rate Viterbi Trellis encoder + decoder over the 104-bit (48 info + 4 tail) FICH channel-bit region (internal/radio/ysf/fich_trellis.go, shared with NXDN SACCH), IQ → C4FM dibit receiver (internal/radio/ysf/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer to feed ysf.DibitSink into ControlChannel.Process, per-frequency state machine emitting cc.locked on sync detect and protocol = "ysf" grants (with the FICH SquelchCode as DG-ID talkgroup) on Header FICH for Group calls — Terminator FICH clears the dedup so the next transmission fires a fresh CallStart
Orchestration In-process pub/sub event bus with typed payloads (Grant / CallStart / CallEnd / DecodeError / ToneAlert / etc.) and a typed events.Stage enum so protocol packages can’t accidentally publish a stage label that drifts from the Prometheus dashboards, System model, JSON-on-disk last-known-CC cache, control-channel Hunter that retunes the SDR and parks on the first responsive frequency
Trunking engine Cross-protocol Grant payload, Trunk-Recorder-format talkgroup DB (CSV + JSON, including a per-TG Scan flag), ScanMode enum (all / list) that gates HandleGrant against the scan list (Emergency bypasses), priority + preemption (emergency overrides, strict-higher), voice-device pool allocator, central state machine emitting CallStart / CallEnd events with a watchdog for silent calls, plus HandleSyntheticCall / EndSyntheticCall entry points for external scanners (conventional FM) that already own their SDR
Scanner subsystem Multi-system control-channel hunter (internal/scanner/cchunt) that round-robins trunked systems on one control SDR, publishes cchunt.progress / cchunt.failed telemetry events, persists last-good CC per system to a JSON cache, and supports operator hold / resume / force-retune; conventional FM scan list (internal/scanner/conventional) with IQ-power squelch (RMS-power dBFS detector, no FM-discriminator required), per-channel hangtime + priority + label, hop-on-silence state machine, synthetic-Grant handoff to the engine so the recorder + call log + API surfaces light up unchanged; operator hold / resume / dwell-on-index; all controlled from the TUI Scanner panel (key 0) + REST cockpit at /api/v1/scanner
Demod pipeline internal/voice/composer subscribes to CallStart events, opens the bound Voice device’s IQ stream, runs an LPF → decimate → optional CMA equalizer → FM demod → optional 75/50µs de-emphasis → optional Kaiser audio LPF → optional audio AGC → optional polyphase L/M resample (or naive decimate fallback) → int16 PCM chain into the recorder, and pings Engine.Touch every second so the silent-call watchdog leaves the call alone
Simulcast / “True I/Q” internal/dsp/equalizer (LMS + CMA blind equalizers) for inter-symbol-interference / multipath mitigation, plus internal/dsp/diversity (Selection + maximal-ratio combiners over a shared Combiner interface) for multi-receiver IQ combining
Tone-out alerting internal/voice/toneout runs Goertzel filters against each Voice device’s PCM stream, matches QC-II two-tone-sequential sequences against operator-configured profiles with per-tone duration + cooldown, and publishes tone.alert events that fan out through SSE / WebSocket / gRPC
Voice recording Vocoder plugin interface + NullVocoder baseline, 16-bit PCM mono WAV writer with patched-length trailers, per-call recorder writing <system>/<tg>/<UTC>_src<id>.wav plus an optional raw-frame sidecar so users can BYO decoder; EDACS ProVoice grants always force a .raw sidecar (the vocoder is patent + trade-secret encumbered) so researchers can decode out-of-band
API proto/*.proto schemas under repo root; HTTP REST (/api/v1/{health,version,systems,talkgroups,calls/active,calls/history,devices,scanner}); operator mutations authenticated via api.auth (bearer token; loopback-bypass under mode: auto) (GET /api/v1/mutations capability probe; POST /api/v1/calls/{serial}/end; PATCH /api/v1/talkgroups/{id} accepts priority/lockout/scan; POST /api/v1/retention/sweep; POST /api/v1/devices/{serial}/tone-reset; PATCH /api/v1/scanner flips scan_mode at runtime; POST /api/v1/scanner/hunt/{system}/{hold\|resume\|retune} and POST /api/v1/scanner/conventional/{hold\|resume\|{index}/dwell} drive the police-scanner cockpit); Server-Sent Events stream (/api/v1/events) — per-device hot-plug surfaces as sdr.attached / sdr.detached, scanner progress as cchunt.progress / cchunt.failed; WebSocket bridge (/api/v1/events/ws); gRPC SystemService + TalkgroupService + AudioService over the same in-process state
Persistence Pure-Go SQLite (modernc.org/sqlite) call log subscribing to CallStart / CallEnd events; newest-first history queries with system / group / time filters; retention sweeper that ages out DB rows and recorded .wav / .raw files past configurable cutoffs
Observability Prometheus collector (events / calls / CC-locked / IQ-underrun / USB-reconnect / decode-error / SDR-attached / build-info series) exposed at /metrics; multi-stage Dockerfile; docker-compose.yml with RTL-SDR USB pass-through, healthcheck, and Prometheus scrape labels
Daemon cmd/gophertrunk run composes everything above into a single supervised process with signal-driven shutdown; every component is opt-in via config.yaml
Testing Per-package unit tests under make test; make integration boots the wired daemon end-to-end (no SDR) and asserts the engine + recorder + call log + metrics + API agree on a synthetic call; make test-integration walks every //go:build integration test across the module; per-protocol “lights up live trunked reception” checks (make integration-cc-{p25,nxdn,dmr,dpmr,edacs,motorola,ltr,mpt1327,tetra,p25p2,ysf}) inject synthesized IQ through the production daemon + receiver + supervisor + API chain; make test-dvsi exercises the patent-encumbered DVSI USB-3000 / AMBE-3003 backend behind -tags dvsi via scripted mock + software-loopback Transports; make vulncheck runs govulncheck against direct + transitive deps; make licenses regenerates the transitive-deps license inventory; make release-dry-run rehearses the release build locally with full ldflags injection. All targets run as separate jobs in GitHub Actions on every PR

Status & known gaps

Once a grant event lands on the bus, the engine + recorder pipeline runs end-to-end: voice device is allocated, the composer pulls IQ → PCM, the recorder writes a WAV (digital-voice protocols decode through the right vocoder via voice.DefaultVocoderForProtocol), the call is logged to SQLite, and the API + TUI surfaces all light up. Pure-Go IMBE / AMBE+2 produce intelligible audio. The CC Hunter supervisor and the conventional FM scanner are constructed by cmd/gophertrunk and expose their state through /api/v1/scanner and the TUI cockpit panel. Every trunked control modulation in the Features table now has an end-to-end IQ → CC chain shipping — the ccdecoder connector constructed by cmd/gophertrunk covers all 10 trunked protocols (P25 Phase 1, P25 Phase 2, DMR Tier III, NXDN, dPMR Mode 3, EDACS, Motorola Type II, LTR, MPT 1327, TETRA TMO) plus DMR Tier II conventional and YSF / D-STAR on the amateur side.

The remaining gaps:

The Go interfaces, event payloads, and per-protocol pipelines all ship for every protocol in the Features table; the remaining work above is per-protocol FEC inner-layer detail + reference data sourcing.

Roadmap

What’s still on the table. Order isn’t fixed; each item is contained to its own package and lands independently.

Recently shipped

Tech stack

Quick start

Download a prebuilt release

The fastest path is the Downloads page on the project site — it has per-platform recipes (Linux / Windows / macOS / Docker), checksum-verification commands, and pointers to the latest tag. Or jump straight to the Releases page on GitHub to grab the artefacts directly:

Platform File What it is
Windows 11 gophertrunk-<ver>-windows-amd64-setup.exe One-click installer (Inno Setup) — single static binary
Windows 11 gophertrunk-<ver>-windows-amd64.zip Portable ZIP — same binary, no installer
Linux gophertrunk-<ver>-linux-amd64.tar.gz Tarballed static binary + sample config
all SHA256SUMS SHA-256 checksums for every artefact in the release

Windows users: after running the installer, follow docs/install-windows.md to swap the RTL-SDR driver to WinUSB via Zadig — the OS won’t see your dongle until that’s done. The installer’s last page links there too.

After install, gophertrunk version reports the build provenance:

$ gophertrunk version
v0.99.0 (sha=abc1234, built=2026-05-13T19:00:00Z)

Build from source

Prerequisites

Just Go 1.25+. The pure-Go RTL-SDR driver doesn’t need librtlsdr / libusb / a C toolchain on the build host. The project’s go.mod pins the toolchain to 1.25.10 (closes the 23 stdlib CVEs in the bare 1.25.0 release); older Go versions auto-download 1.25.10 via Go’s toolchain mechanism.

See docs/hardware.md for runtime udev rules and DVB-driver blacklisting on Linux.

Build, test, run

make build                    # produces ./bin/gophertrunk
make test                     # go test -race ./...
make integration              # boots the wired daemon end-to-end (no SDR needed)
make test-integration         # every //go:build integration test across the module
make test-dvsi                # DVSI USB-3000 / AMBE-3003 backend (under -tags dvsi)
make vulncheck                # govulncheck against direct + transitive deps
make licenses                 # regenerate THIRD_PARTY_LICENSES.csv
make release-dry-run          # rehearses the release.yml linux build locally
make integration-cc           # P25 Phase 1 "lights up live trunked reception"
make integration-cc-nxdn      # NXDN "lights up" — synthesizes spec FEC chain
make integration-cc-dmr       # DMR Tier III "lights up" — Aloha CSBK via BPTC
make integration-cc-dpmr      # dPMR Mode 3 "lights up" — FS3 sync + 80-bit CSBK
make integration-cc-edacs     # EDACS "lights up" — GFSK + BCH(40, 28, 2) CCW
make integration-cc-motorola  # Motorola Type II "lights up" — GFSK + BCH(64, 16, 11) OSW
make integration-cc-tetra     # TETRA TMO "lights up" — π/4-DQPSK + full §8.3.1 chain
make integration-cc-p25p2     # P25 Phase 2 "lights up" — H-DQPSK + trellis MAC PDU
make integration-cc-mpt1327   # MPT 1327 "lights up" — audio-band FFSK + BCH(63, 38)
make integration-cc-ltr       # LTR "lights up" — sub-audible NRZ at 300 baud
make integration-cc-ysf       # YSF "lights up" — 4800-baud C4FM 480-dibit frames

./bin/gophertrunk version
./bin/gophertrunk sdr list                # enumerates attached dongles
./bin/gophertrunk run -config config.yaml

# Out-of-band: decode a captured .raw frame sidecar to a WAV using
# the pure-Go IMBE / AMBE+2 vocoders. The .raw sidecar is written
# alongside each call's WAV when the recorder's raw-frames option
# is enabled.
./bin/gophertrunk decode -in call.raw -out call.wav -vocoder imbe
./bin/gophertrunk decode -list-vocoders

A starter config.example.yaml is in the repo root — copy it, set the serial of your dongle from gophertrunk sdr list, point talkgroup_file at a Trunk-Recorder-format CSV, and you’re going.

Docker

docker compose up -d
curl -s http://localhost:8080/api/v1/health
curl -s http://localhost:8080/metrics | grep gophertrunk_build_info

docs/hardening.md has the full operator playbook — Prometheus catalogue, USB pass-through recipe, smoke tests.

Repository layout

cmd/gophertrunk/        daemon entrypoint + sdr list CLI + read+write TUI cockpit
internal/tui/           bubbletea TUI: 11 panels (Dashboard, Systems, Talkgroups, Active, History, Events, Tones, Metrics, Devices, Scanner, Settings) over REST+SSE
internal/sdr/           Driver interface, pool, mock
internal/sdr/rtlsdr/usb/      Pure-Go USB transport: Linux USBDEVFS, Windows WinUSB, macOS IOKit (purego), mock
internal/sdr/rtlsdr/rtl2832u/ RTL2832U register/I2C layer (sample-rate, IF, FIR, GPIO, I2C bridge)
internal/sdr/rtlsdr/tuners/   R820T/R820T2/R828D + E4000 + FC0012 + FC0013 + FC2580 tuner drivers
internal/sdr/rtlsdr/purego/   sdr.Driver+sdr.Device wire-up; canonical "rtlsdr" registrant
internal/dsp/           Channelizer, filters, demods, sync, FFT
internal/radio/         framing/ + p25/{phase1,phase2}/ + dmr/{tier2,tier3}/ + nxdn/ + tetra/ + dpmr/ + edacs/ + ltr/ + mpt1327/ + motorola/ + ysf/ + dstar/ (per-protocol packages; each has its own receiver/ + ControlChannel.Process adapter)
internal/trunking/      System, talkgroup DB (Scan flag), engine (ScanMode, HandleSyntheticCall), priority, CC hunter primitive, cc cache
internal/scanner/       cchunt/ (multi-system CC supervisor) + conventional/ (analog FM scan list) + ccdecoder/ (IQ→CC connector)
internal/voice/         Recorder, vocoder plugin, demod composer
internal/storage/       SQLite call log + retention sweeper
internal/api/           HTTP REST + SSE + WebSocket + gRPC
internal/metrics/       Prometheus collector
internal/events/        In-process pub/sub bus
internal/config/        YAML loader
proto/                  *.proto schemas (events, system, talkgroup, audio)
docs/                   architecture · hardware · vocoders · hardening
samples/                drop-zone for real-air captures that close the remaining FEC follow-ups in docs/opt-in-features.md §5 (nxdn/, ysf/, tetra/, dmr-tier2/, mpt1327/ — each subfolder has a README documenting the expected capture format + metadata schema)

TUI

GopherTrunk ships an operator TUI that points at a running daemon. From a second terminal:

gophertrunk tui                    # default: http://127.0.0.1:8080
gophertrunk tui -server http://10.0.0.5:8080
gophertrunk tui -no-color          # disable ANSI colour
gophertrunk tui -insecure          # skip TLS verification

Eleven panels covering every read surface plus the operator scanner cockpit, vim-style navigation, live SSE event stream, periodic REST refresh, automatic reconnect on disconnect:

Key Action
Tab / Shift+Tab next / previous panel
19, 0 jump to Dashboard / Systems / Talkgroups / Active / History / Events / Tones / Metrics / Devices / Scanner
Ctrl+P open fuzzy command palette (panel jumps, system / TG / device drill-ins, audio mutations, retention sweep, scanner hold/resume)
Ctrl+T toggle theme (dark ↔ monochrome)
j / k move row up / down inside a table
/ filter (Talkgroups, Events)
s cycle sort (Talkgroups)
S toggle scan flag (Talkgroups; mutates)
Enter open detail card (Systems, Talkgroups) or dwell (Scanner conv row)
h hold/resume highlighted system or conv channel (Scanner; mutates)
r force re-hunt highlighted system (Scanner; mutates)
m cycle scan_mode list↔all (Scanner; mutates)
p pause auto-scroll (Events)
r reload (History)
? toggle help
q / Ctrl+C quit

The tab strip is also clickable, and a left-click on any data row in the Systems / Talkgroups / Active / History / Devices / Tones / Metrics panels moves the cursor onto that row — pair it with Enter to open the detail card or with a mutation key (e to end the highlighted active call, R to reset the highlighted tone detector, L to toggle conventional channel lockout, etc.) to act on the selection. Scroll-wheel ticks advance the cursor one row at a time in the same panels. Picking a system / talkgroup / device from the command palette pre-positions the destination panel’s cursor on the matching row before opening the detail modal, so keyboard and mouse paths converge on the same selection.

Settings is a tabbed inspector ([ / ] to cycle) covering Daemon · Storage · Audio · Recording · Tones · API · Vocoders · SDR · FEC — every config knob the daemon reads is visible.

For mutation actions (end-call; set talkgroup priority / lockout / scan; retention-sweep; tone-detector reset; scanner cockpit hold/resume/retune/dwell + scan_mode flip) the HTTP API uses bearer-token authentication (api.auth.mode). The default auto mode bypasses auth on loopback binds (127.0.0.1 / ::1) — peer- cred via kernel-enforced reachability is a reasonable trust proxy for single-host operator boxes — and requires a Authorization: Bearer <token> header on every mutation request when the listener binds to a public interface. See the API authentication section below; docs/tui.md documents the matching --write TUI flag.

API authentication

The HTTP API’s mutation endpoints (every write route: end-call, talkgroup priority/lockout/scan, retention sweep, tone-detector reset, scanner cockpit, audio cockpit, manual tune) authenticate via bearer tokens. Configure under api.auth in config.yaml:

api:
  http_addr: "127.0.0.1:8080"
  auth:
    mode: "auto"                # auto | required | disabled
    # token: "inline-token"     # discouraged; use token_file
    token_file: "/etc/gophertrunk/api-token"
    trusted_networks:
      - "10.0.0.0/8"

Policy modes:

Mode Behaviour
auto (default) Require a token on non-loopback binds; bypass 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 behaviour. The daemon logs a warning at startup.

Token storage. token_file is preferred — the secret stays out of config.yaml, and the daemon re-reads the file on every request so operators can rotate without a restart. Inline token is supported for ephemeral / test setups.

Trusted networks. A CIDR allowlist of source addresses that bypass the token check under auto mode. Loopback prefixes are implicit; list private subnets here if the daemon binds to a LAN interface and you trust everything on that segment. The middleware reads RemoteAddr only — X-Forwarded-For is intentionally ignored so the bypass isn’t forgeable by a hostile upstream proxy.

Header format. Standard RFC 6750:

Authorization: Bearer <token>

The token is compared with crypto/subtle.ConstantTimeCompare. Mutation requests without a valid token return 401; the body carries the same {"error":"..."} envelope every other 4xx response uses.

TUI client. The operator TUI sends the token automatically when launched with gophertrunk tui -token <value> or gophertrunk tui -token-file <path>. -token-file is re-read on every request so daemon-side rotation works without restarting the TUI. The GOPHERTRUNK_API_TOKEN env var is honoured as a fallback. 401 / 403 responses surface as a toast that points the operator at the right flag.

Capability probe. GET /api/v1/mutations is always open and reports the daemon’s policy plus whether the current request would be accepted:

{
  "auth_mode": "auto",
  "can_mutate": true,
  "allow_mutations": true,
  "engine_writable": true,
  "retention_writable": true,
  "tones_writable": false
}

allow_mutations is the legacy alias of can_mutate; new clients should prefer can_mutate.

Migration from allow_mutations: true. The legacy flag is still recognised: setting it to true logs a deprecation warning at startup and maps to auth.mode: disabled so existing wide-open deployments keep working. Migrate to explicit auth.mode at your next config edit.

FEC opt-outs

Every protocol that has a public-spec FEC chain ships its spec-correct decoder chain on by default: the connector constructs each ControlChannel with the full on-air FEC layer applied, and operators with pre-stripped capture files (DSD-FME -r dumps, OP25 fixtures, MMDVMHost / DSDcc test data) opt out per-system with <key>: off in config.yaml. Empty / absent keys map to the new on-default for every protocol.

Verify which protocols are on / off in the Settings panel of the TUI — it lists every configured system with a one-line summary of its FEC state (channel coding: on (colour=…, sch/f), viterbi: spec, bch: on, etc.). The panel is read-only; runtime mutation is a future PR. To change a mode, edit config.yaml and restart the daemon.

Protocol YAML key(s) On (default) Off (opt-out)
TETRA tetra_colour_code (uint32, low 30 bits — required for non-BSCH), tetra_channel ("sch/hd" / "sch/f" / "sch/hu" / "bsch" / "aach", default sch/hd), tetra_channel_coding ("" / "on" / "off") Full ETSI EN 300 392-2 §8.3.1 type-5 → type-1 chain (descramble + deinterleave + depuncture + Viterbi + CRC-16 verify + tail strip) per burst. tetra_colour_code of 0 is only valid for BSCH; non-BSCH channels need the per-cell colour code or descrambling produces garbage (the connector warn-logs this case). tetra_channel_coding: off falls back to the legacy 48-dibit raw-PDU path. CRC will fail on live captures; only useful for pre-stripped fixtures.
LTR ltr_fcs_mode ("" / "on" / "off"), ltr_manchester_mode ("" / "on" / "soft" / "strict" / "off" / "nrz") fcs: on — CRC-7 FCS check against sdrtrunk’s CRCLTR.java layout. manchester: soft — majority-decode each pair (matches the dominant on-air encoding for sub-audible LTR signaling). fcs: off skips the CRC check (synthesized fixtures whose FCS trailer isn’t populated). manchester: off / nrz treats the stream as raw NRZ (synthesized NRZ fixtures).
P25 Phase 2 p25_phase2_trellis_mode ("" / "on" / "off"), p25_phase2_rs_mode ("" / "on" / "off"), p25_phase2_scrambler_mode ("" / "on" / "probe" / "off") trellis: on — 4-state ½-rate trellis FEC over the MAC PDU window (146 channel dibits → 72 info dibits per TIA-102.AABF). rs: off — outer RS(24, 16, 9) verification per TIA-102.BAAA-A §5.9 defaults off; flip on to drop MAC PDUs with non-zero syndromes. scrambler: off — PN44 descrambler per TIA-102.BBAC-1 §7.2.5 defaults off. trellis: off — legacy 72-dibit raw-MAC-PDU path for pre-stripped fixtures. rs: on — verify RS(24, 16, 9) syndromes on the trellis-decoded MAC PDU. scrambler: on — XOR the trellis-decoded 144-bit MAC PDU with the PN44 sequence starting at the configured per-burst offset. scrambler: probe — walk all 12 spec-defined slot offsets from Figure 7-5 and accept the first that passes RS verification (requires rs: on; degrades to offset-0 descrambling otherwise).
NXDN nxdn_viterbi_mode ("" / "spec" / "on" / "off") spec — full NXDN-TS-1-A rev 1.3 §4.5.1.1 outbound CAC chain (150 dibits → deinterleave 25×12 → depuncture 50/350 → K=5 Viterbi → 16-bit CRC verify → 155 info bits). on — intermediate 92-dibit K=5 Viterbi path for older MMDVMHost / DSDcc fixtures. off — legacy 44-dibit raw-CAC path for pre-stripped fixtures.
EDACS edacs_bch_mode ("" / "on" / "off") BCH(40, 28, 2) with single/double-bit correction over the 40-bit on-wire CCW; the effective CCW carries 28 info bits (Command + Status + Address + high LCN bits), the remaining bits become BCH parity. Falls back to the legacy pre-stripped 40-bit CCW; payload struct’s LCN bit 0 + Aux fields are treated as data instead of parity.
MPT 1327 mpt1327_bch_mode ("" / "on" / "off") BCH(63, 38) decode over the 64-bit on-wire codeword. Falls back to the legacy 38-bit pre-stripped codeword.
Motorola Type II motorola_bch_mode ("" / "on" / "off") Two 64-bit BCH(64, 16, 11) codewords reassembled into the 32-bit OSW with single- through 11-bit-error correction per codeword. Falls back to the legacy 32-bit raw-OSW path for pre-stripped DSD-FME -r fixtures.

All string values are case-insensitive with whitespace tolerated; recognised on-values include "" (empty) / "on" / "true" / "1" (NXDN also accepts "spec"; LTR Manchester accepts "soft" / "strict"); off-values are "off" / "false" / "0" (LTR Manchester also accepts "nrz"). Unrecognised values fall back to the on-default with a warn-level log line (“ccdecoder: unrecognised <key>; falling back to on”) so a typo doesn’t silently break the decoder.

Each protocol’s ControlChannel exposes matching getters (tetra.ControlChannel.ChannelCoding() / ExpectedChannel() / ColourCode(), ltr.ControlChannel.FCSMode() / ManchesterMode(), p25phase2.ControlChannel.TrellisMode(), nxdn.ControlChannel.ViterbiMode(), edacs.ControlChannel.BCHMode(), mpt1327.ControlChannel.BCHMode()) so tests + observability code can introspect the configured state without poking at unexported fields. The TUI Settings panel reads these via the /api/v1/systems endpoint’s per-system DTO, which carries every opt-in field as a omitempty JSON value.

Documentation

Project docs

Project metadata

License

See LICENSE.