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:
- Per-protocol on-air FEC layers — most shipping, some inner
layers TODO. Every protocol’s
ControlChannel.Processadapter ships a working IQ → CC chain (see FEC opt-outs for the full reference). The spec-correct chain is on by default for every protocol; operators with pre-stripped capture files opt out per-system. TETRA ships the full ETSI EN 300 392-2 §8.3.1 chain (descramble + deinterleave + depuncture + Viterbi + CRC-16); DMR Tier III + Tier II both ship full BPTC(196,96) + RS(12,9)- CSBK CRC; LTR ships CRC-7 FCS + Manchester soft decode; D-STAR ships the full JARL DV-mode header chain (K=5 R=1/2 + PN15 + 22×30 interleaver); P25 Phase 1, P25 Phase 2 trellis, EDACS BCH(40,28,2), MPT 1327 BCH(64,48,2), Motorola BCH(64,16,11) all ship as opt-out. The inner FEC layers still pending:
- NXDN per-protocol interleaver + puncture.
ViterbiSpecmode runs the full §4.5.1.1 chain;ViterbiOnis the simpler bare-bones path the older MMDVMHost / DSDcc fixtures use. Both are wired through the connector; the interleaver/puncture detail-level matching against captured MMDVMHost transmissions is the calibration step that lands next. - P25 Phase 2 FEC chain (trellis + outer RS + PN44
scrambler + per-burst offset probe, all shipping). The
full TIA-102 chain wraps the MAC PDU in three layers, each
opt-in via a per-system flag:
- The inner 4-state ½-rate trellis decoder
(
SetTrellisMode(TrellisOn)) handles the on-wire FEC. - The outer RS(24, 16, 9) over GF(2^6) per TIA-102.BAAA-A
§5.9 (
SetRSMode(RSOn)) drops MAC PDUs whose syndromes are non-zero. - The PN44 LFSR scrambler per TIA-102.BBAC-1 §7.2.5 with a
per-burst slot-offset blind probe
(
SetScramblerMode(ScramblerProbe)) walks all 12 slot offsets from Figure 7-5 and accepts whichever passes RS verification — no external superframe synchronization is required. The seed is derived from the per-system (WACN, SystemID, NAC) triple (SetScramblerSeed/framing.PN44SeedFromIdentity).
The previous follow-ups — full superframe-aware per-burst offset tracking AND NSB-driven runtime seed installation — now both ship. The
ScramblerProbeblind-probe walks all 12 slot offsets; theControlChannel.Ingestpath auto-recomputes the seed from every Network Status Broadcast - Update MAC PDU (opcode 0xFB) viapn44SeedFromNSB. Per-system static config still provides the initial seed for the first few PDUs before NSB lands, and stays available as an override. - The inner 4-state ½-rate trellis decoder
(
MPT 1327 sync detection + bit-error-tolerant CWSC(now shipping). The BCH(64, 48, 2) per-codeword check + the 16-bit Codeword Synchronisation Code (1100010011010111) alignment per the MPT 1327 standard both ship. The Process adapter now matches CWSC against a Hamming-distance threshold (default 2 bits out of 16, matching commercial MPT 1327 receivers on noisy on-air captures) instead of exact-match, falling back to the legacy “first parseable codeword” alignment when no CWSC window is within tolerance. Operators replaying pre-stripped synthesized fixtures opt back into exact-match per system viampt1327_cwsc_tolerance: 0. The previously-cited “inter-codeword bit-interleaver across 5-codeword CCDB groups” doesn’t exist in the standard; MPT 1327 transmits 64-bit codewords back-to-back at 1200 bps FFSK with no inter-codeword bit permutation. No remaining MPT 1327 spec follow-ups.YSF FICH on-air interleaver / puncture validation(now shipping the spec-level codec). The K=5 ½-rate Trellis encoder + decoder (internal/radio/ysf/fich_trellis.go) round-trip cleanly in unit tests.EncodeFICHOnAir/DecodeFICHOnAirnow layer the full on-air chain — puncture (drop channel-bit positions{0, 1, 102, 103}) plus column-major 10×10 interleave (out[k] = depunctured[(k%10)*10 + (k/10)]) — per the MMDVMHost / DSDcc / Pi-Star reference. Every single-bit-flip in the 100-bit on-air stream is repaired by the Viterbi (TestFICHOnAirRecoversFromSingleBitFlipexhaustively confirms all 100 positions). On-air capture validation against a real Yaesu transmission is the remaining real-air-blocked piece — if the captured FICH fails CRC after the on-air decoder, the alternate-schedule swap is a two-line change documented insamples/ysf/README.md.- TETRA on-air recovery margins. Unit tests round-trip clean fixtures end-to-end; on-air recovery margins (Viterbi correction depth vs. real co-channel + adjacent-channel interference) need a live capture to characterise.
DMR Tier II synthesized IQ fixture(now shipping). The Tier II pipeline + Process adapter + unit test all shipped in PR #184; the end-to-end integration test (TestDaemonCCDecodesDMRTier2) was previouslyt.Skip‘d because the synthesized Voice LC Header IQ fixture’s symbol distribution stresses the Mueller-Müller clock loop harder than Tier III’s structurally-identical CSBK Aloha fixture. The diagnostic test (TestDMRTier2VsTier3SymbolDensity/TestDMRTier2SlotTypeVsPayloadIsolationincmd/gophertrunk/dmr_tier2_diagnostic_test.go) localised the divergent statistic to the BPTC(196, 96)-encoded payload’s class-3 dibit overrepresentation (21.4% Tier II vs 5.1% Tier III) and matching mean-transition magnitude (1.27 vs 0.90); the RS(12, 9) seed0x96 0x96 0x96and the BPTC parity rows distribute high-Hamming-weight bits throughout the channel-bit output. The fix lives ininternal/scanner/ccdecoder/pipelines.go’snewDMRTier2Pipeline: lowering the per-protocol pipeline ClockGain from 0.025 (the value shared with Tier III) to 0.015 keeps the MM loop locked under the harder symbol distribution. Receiver locks within ~100 ms of the first burst; the more conservative gain stays well within the loop’s noise margin on live captures.- Digital-voice level calibration. Pure-Go IMBE / AMBE+2
emit real audio end-to-end. The comparison harness at
internal/voice/calibrate/is ready; reference data (captured P25 P1 / DMR voice exchanges plus DSD-FME / OP25 decodes belong atinternal/voice/{imbe,ambe2}/testdata/) is the remaining gap. Knox / call-alert AMBE+2 tones (b₁ ∈ [144, 163]) are vendor-specific and stay silent until per-vendor frequency tables land. See docs/vocoders.md for the licensing posture. - CTCSS + DCS sub-audible squelch + tail-fade on call end. The
conventional FM scanner optionally gates squelch on a sub-audible
tone or digital code in addition to IQ power, so adjacent-system
traffic on the same frequency doesn’t trigger a false dwell.
Per-channel YAML:
```yaml
conventional:
- label: “Sheriff Repeater”
frequency_hz: 155895000
tone:
mode: ctcss # ctcss | dcs | none
ctcss_hz: 100.0 # required for ctcss
# dcs_code: “023” # required for dcs (3-digit octal)
```
Both detectors share an
FM discriminator → single-pole IIR low-pass → bit/bin detectorpipeline. CTCSS runs a Goertzel at the configured frequency plus two reverse-bin Goertzels at ±5 Hz; a match requires the target bin both to exceed the magnitude floor AND to dominate the largest reverse bin by a configurable factor (default 1.5×). This rejects adjacent EIA codes whose spectral leak would otherwise show up in the target bin under the 5 Hz Goertzel resolution; the 38-code list has codes spaced as close as ~3 Hz at the low end and the single-bin path was prone to false-trigger on them. Reuses the existinginternal/voice/toneoutGoertzel primitive (~200 ms block). DCS recovers the 134.4 baud sub-audible NRZ stream, slides a 23-bit window, and matches against the 46 precomputed rotations (23 cyclic shifts × 2 polarities) of the Golay(23,12,7) codeword built from the configured 3-digit octal code, reusing theinternal/radio/framing.GolayEncode23_12primitive shared with P25 Phase 1 IMBE. Hangtime triggers on either condition (carrier OR tone/code) going false so a transmitter dropping the gate hangs up just like a true carrier drop. The scanner auto-bumps per-channel min dwell to 250 ms whenever any channel has a tone gate so the bit/Goertzel windows have time to fire. The composer also emits a 10 ms linear fade-out tail on call end (internal/voice/composer) so the audio sink doesn’t hear an abrupt squelch-close click on the host speakers.
- label: “Sheriff Repeater”
frequency_hz: 155895000
tone:
mode: ctcss # ctcss | dcs | none
ctcss_hz: 100.0 # required for ctcss
# dcs_code: “023” # required for dcs (3-digit octal)
```
Both detectors share an
- gRPC
AudioService.StreamAudiolive audio fan-out. The daemon now ships anapi.AudioPublisherthat fans decoded PCM from the per-call composer to any number of gRPC subscribers.StreamAudio(defined inproto/audio.proto) readsdevice_serials+talkgroup_idsas filter allow-lists; the default empty filter forwards every call. Each subscriber gets a 64-frame bounded channel — slow clients drop frames on full rather than back-pressuring the composer (per-subscriber and publisher-wide drop counters surface via the publisher’sStats()method). The publisher tracks per-deviceGrantcontext off the events bus so every frame carries talkgroup + system metadata. Disabled-cleanly: when the daemon runs without a composer (no SDR pool, audio off, etc.) the RPC returnsUnavailableso a remote client gets a clean error instead of a hanging stream. Try it locally withgrpcurl -plaintext -d '{}' 127.0.0.1:50051 gophertrunk.v1.AudioService/StreamAudio. - Manual VFO tune from the TUI / API. The Scanner panel now binds
fto a bubbles/textinput overlay: type a frequency in MHz, Enter, and the conventional FM scanner appends a runtime “manual” channel and forces dwell on it. Same flow available over REST asPOST /api/v1/scanner/manual_tune(andDELETE /api/v1/scanner/manual_tune/{idx}to revoke), gated behindapi.auth(see API authentication). To run manual tune without any staticscanner.conventionalentries, setscanner.manual_tune_enabled: truein config — the daemon then constructs the conventional scanner against the last Voice SDR regardless of the static channel count.internal/scanner/conventionalnow accepts an empty seed channel list and exposesAddTemporaryChannel/RemoveTemporaryChannelso the same VFO surface is callable from any embedder. - Live audio playback to speakers + TUI / API audio cockpit. The
daemon ships a
voice.Playersink (internal/voice/player) that routes decoded PCM to the host’s default audio output. On Linux it talks tolibasound2.so.2directly viagithub.com/ebitengine/purego— no cgo, nolibasound2-devat build time, no pkg-config; the runtime library ships on every standard Linux image. macOS / Windows usegithub.com/ebitengine/oto/v3(CoreAudio + WASAPI, also via purego). Whenaudio.enabled: trueis set in config the per-call composer and the conventional FM scanner fan PCM into the player alongside the existing WAV recorder, so calls play out the host’s default output device in real time. Volume / mute / recording can be toggled live: the TUI’s Scanner panel binds+/-for volume (5% step),Mfor mute, andRfor record on/off; the same knobs are exposed asGET/PATCH /api/v1/audiofor remote clients (PATCH gated byapi.authlike every other write endpoint; see API authentication). The recorder gate stops new WAVs from landing without truncating in-flight sessions, matching scanner muscle memory. Disabled by default; headless servers stay silent and continue to record WAVs identically to before. New CLI:gophertrunk audio listmirrorssdr list. Iflibasound2.so.2isn’t reachable (stripped-down container, etc.) the backend logs once and falls back to the null player so the rest of the daemon keeps running.
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.
- DVSI USB-3000 / AMBE-3003 hardware backend (USB transport).
The
Vocoder+ AMBE-3003 wire protocol +voice.Vocoderinterface conformance ship ininternal/voice/dvsi/behind-tags dvsi; the package’sinit()registers"dvsi"withvoice.DefaultRegistry. CI exercises the wire protocol + Vocoder plumbing through the scripted mock Transport and the software-loopback Transport (make test-dvsi). The USB / FTDI bulk-endpoint plumbing that talks to a physical chip remains a stub returningErrNoDevice— the recorder fallback chain activates cleanly when no chip is connected. The actual FTDI hardware integration lands when a DVSI USB-3000 is available for round-trip testing. - Vocoder level calibration (reference data). The plumbing
ships — comparison harness at
internal/voice/calibrate, per-vocoder testdata READMEs atinternal/voice/{imbe,ambe2}/testdata/, the end-to-end recipe at docs/voice-calibration.md, and a one-off CLI wrapper atcmd/voice-calibrate(rungo run ./cmd/voice-calibrate -raw call.raw -ref-wav ref.wav -vocoder imbe). Operators just need to drop reference WAVs decoded by DSD-FME / OP25 from the same.rawinto testdata; the existing calibrate tests run unguarded once both files are present. AMBE+2 DTMF dual-tone synthesis (b₁ ∈ [128, 143]) is wired against the ITU-T Q.23 4×4 matrix; knox / call-alert pairs (b₁ ∈ [144, 163]) are vendor-specific — operators with a per- vendor reference register the (freqA, freqB) pair viaambe2.SetKnoxToneand the matching tone frames synthesise through the same dual-tone path as DTMF. - YSF on-air interleaver / puncture validation (real-air capture).
The spec-level on-air codec ships in
internal/radio/ysf/fich_trellis.go’sEncodeFICHOnAir/DecodeFICHOnAirper the MMDVMHost / DSDcc / Pi-Star reference (puncture positions{0, 1, 102, 103}, column-major 10×10 interleave). Unit tests confirm every single-bit-flip is Viterbi-corrected. The remaining work is calibration against a real captured YSF transmission — if the captured FICH fails CRC after on-air decode, swap to the alternate schedule persamples/ysf/README.md.
Recently shipped
- Capture-spec acceptance criteria for every real-air-blocked
follow-up. Each
samples/<proto>/README.mdnow documents the explicit numerical thresholds a contributor with hardware can run a capture against to close the corresponding follow-up: TETRA wants 5 s lock latency + ≥ 90% frame recovery- a new (not-yet-wired)
gophertrunk_tetra_viterbi_correctionsPrometheus histogram; NXDN wants ≥ 80% CRC-verified CAC bursts - SystemID match; MPT 1327 wants ≥ 95% true-positive lock rate
- a non-decreasing tolerance sweep; DMR Tier II wants
byte-for-byte FLC match + clean Terminator-with-LC handling.
The DMR Tier II and MPT 1327 follow-ups are already closed
algorithmically (PR-A, PR-C); their captures are now optional
secondary validation rather than the blocker. The
samples/README.mdtop-level table summarises status + acceptance criteria across all five protocols.
- a new (not-yet-wired)
- Release-ready version metadata + AMBE+2 patent banner.
internal/version/now exposesVersion,Commit,BuildTime, and aString()formatter ("vX.Y.Z (sha=…, built=…)"); all three are populated via-ldflagsby the Makefile + release workflow. The daemon logs a one-line AMBE+2 patent-posture notice at startup pointing atdocs/vocoders.md; setGOPHERTRUNK_QUIET_BANNER=1in CI / test harnesses to suppress it. Newmake release-dry-run VERSION=v0.99.0target rehearses the release build locally so ldflags + packaging surface before a tag is cut — seeCONTRIBUTING.md§”Cutting a release”. - CI gates: govulncheck + license audit + full-tree integration
run.
.github/workflows/ci.ymlgains avulncheckjob (govulncheck against the direct + transitive dependency graph), alicensesjob (regenerates the transitive-deps inventory via google/go-licenses and diffs against the committedTHIRD_PARTY_LICENSES.csv), and anintegrationjob that walksmake test-integrationacross the whole module (the existingbuild-testjob runsmake integrationagainstcmd/gophertrunk/only; this future-proofs against integration-tagged tests landing in other packages). NewMakefiletargets:make vulncheck,make licenses,make test-integration.THIRD_PARTY_LICENSES.mdships a hand-curated direct-deps table + the ISC attribution for the mbelib-derived AMBE+2 / IMBE codebook tables. - Operational docs landed.
SECURITY.mddocuments the vulnerability disclosure process (private GitHub security advisories), supported versions, in-scope vs. out-of-scope issues, and the maintainer’s response-time SLAs.CONTRIBUTING.mdcovers the dev setup, the house-style conventions, and the PR scoping rules.CHANGELOG.mdseeds a Keep-a-Changelog with every user-visible change since the calibration / hardening pass.docs/gophertrunk.serviceis an example systemd unit (DynamicUser + ProtectSystem + USB device-allow) operators install at/etc/systemd/system/. - Optional TLS on HTTP + gRPC + extended health endpoint.
api.tls_cert/api.tls_keyinconfig.yamlswitches both the HTTP REST/SSE/WebSocket server and the gRPC server to TLS; the daemon refuses to start when one is set without the other. Plain TCP stays the default for loopback / trusted-LAN deployments.GET /api/v1/healthnow returnspool_attached_count,active_calls,db_connected,metrics_enabled,auth_mode, andversionalongside the legacystatus+now— supports two-field k8s / Nomad readiness probes that distinguish “process up” from “actually working”. See docs/hardening.md for the TLS recipe and docs/hardening.md for the health schema. - API server hardening: HTTP timeouts + gRPC keep-alive + drain
window.
internal/api/server.gonow setsReadTimeout/WriteTimeout/IdleTimeouton the HTTP server (30 s / 30 s / 120 s) on top of the existingReadHeaderTimeout. SSE (/api/v1/events) opts out ofWriteTimeoutper-request viahttp.ResponseController.SetWriteDeadline(time.Time{})so the long-lived stream isn’t torn down mid-call; the WebSocket endpoint hijacks the connection on Upgrade and is unaffected.internal/api/grpc.goconfigureskeepalive.ServerParameters(30 s idle ping, 10 s ack timeout) +EnforcementPolicy(5 s min-time floor,PermitWithoutStream: true) so long-livedAudioService.StreamAudiosubscribers detect dead peers without back-pressuring the publisher. Shutdown ctx bumped from 5 s to 30 s so in-flight SSE / WebSocket / audio-stream subscribers drain cleanly on a daemon restart. See docs/hardening.md for the full knob reference. - Runtime theme toggle (Ctrl+T) + docs/tui.md sync. The dark
and monochrome palettes have lived in
internal/tui/themesince the operator-console PR but weren’t reachable from the UI;Ctrl+T(and a “Toggle theme” entry in the command palette) now cycles between them at runtime. Apanels.ThemeChangedMsgbroadcast lets eachbubbles/table-backed panel re-apply its cachedtableStyles()so the swap takes effect on the next render without a restart.docs/tui.mdwas rewritten end-to-end to cover everything shipped since the operator-console work: the Settings panel and its tab cycle, the Ctrl+P command palette, mouse hit-testing and scroll-wheel forwarding, Revealer-driven cursor pre-positioning, async history refresh, and every audio / scanner / runtime endpoint that wasn’t in the old reference. - Mouse coverage on every table panel + scroll-wheel scroll.
The
MouseAwareinterface added in the Revealer PR now lives on Active, History, Tones, and Metrics in addition to Systems, Talkgroups, and Devices — left-clicks on a data row move the cursor onto that row in every table-backed panel. Scroll-wheel ticks are forwarded the same way (one-row-per-tick up or down) because bubbles/table v1.0.0 doesn’t handleMouseMsgitself. TheMouseAwaresignature changed fromHandleMouseAt(localX, localY int)toHandleMouse(msg tea.MouseMsg, localY int)so panels can distinguish a click on a row from a wheel tick from a button release. A sharedhandleTableMousehelper centralises the left-press + wheel switch so every table panel handles input the same way. - TUI Revealer / MouseAware: palette + mouse converge on the same
row. Picking a system / talkgroup / device from
Ctrl+Pnow jumps to the destination panel and pre-positions the panel’s cursor on the matching row before opening the detail modal — follow-up keystrokes (Enter, mutation keys) operate on the selection without an extra scroll. A newpanels.Revealerinterface formalises the contract; SystemsPanel (by name), TalkgroupsPanel (by decimal ID), DevicesPanel (by serial), and ScannerPanel (bysys:<name>/conv:<idx>) implement it. Mouse hit-testing was extended past the tab strip via a siblingpanels.MouseAwareinterface: left-clicks on data rows in the three table panels translate the click position to a row index (accounting for the canonical panelFrame chrome offset) and callSetCursor. Chrome clicks are ignored; out-of-range clicks clamp to the last row. - Async history refresh off the Update goroutine. The history
panel was the one remaining surface that built its bubbles/table
rows inline inside
Update. The conversion now runs in atea.Cmdand commits via a routedHistoryRefreshedMsg; apendingAtguard prevents duplicate dispatch and stale results (a newer snapshot landed mid-flight) are dropped silently. The reducer stays unblocked on row formatting that doesn’t need to hold it. - Direct-ioctl ALSA backend (drops the runtime libasound2 dep).
Setting
audio.device: ioctl(orioctl:hw:C,Dfor a specific card / device) on Linux selects a direct-kernel backend that opens/dev/snd/pcmC{card}D{device}pand drives the playback state machine viaSNDRV_PCM_IOCTL_*syscalls. Nolibasound2.so.2at runtime, nopurego.Dlopen, no cgo — useful for distroless / Alpine / scratch container images that don’t ship the userspace library but do have the kernel sound subsystem available. Defaults to card 0, device 0; format pinned to S16_LE mono ataudio.sample_rate, period sized fromaudio.buffer_ms. The dlopen path stays the Linux default because it auto-negotiates with the hardware; the ioctl path uses fixed values so it only works when the underlying device natively supports them. - AMBE+2 DTMF dual-tone synthesis. Tone frames with
b₁ ∈ [128, 143] (the AMBE+2 DTMF range) now synthesise the
correct summed sinewaves instead of routing through silence.
Sixteen-entry b₁ → (low Hz, high Hz) lookup table sourced from
ITU-T Q.23’s 4×4 DTMF matrix (rows 697/770/852/941 ×
cols 1209/1336/1477/1633); the b₁ → key mapping follows the
AMBE+2 layout shared by mbelib / DSDcc / DSD-FME — 128 is “1”,
143 is “D”. Two oscillator phases ride across frame boundaries
so held keys are click-free. Knox / call-alert pairs
(b₁ ∈ [144, 163]) are vendor-specific (Motorola Trbo vs.
Hytera vs. generic) and stay routed through silence pending
per-vendor frequency tables — operators who need them can drop
rows into the
ambeDualToneTableupper range. audio.stateSSE event for instant TUI convergence. PATCH/api/v1/audionow publishes the resulting state on the events bus asKindAudioState, which the SSE pump forwards to every subscriber. The TUI listens and re-fetches the audio snapshot on receipt, so two TUIs / a TUI plus acurlPATCH converge inside one SSE round-trip instead of waiting up to 3 s for the next poll tick. The payload is the sameAudioStatusDTOthe HTTP response carries — clients can use it directly or just treat the event as a poll trigger.- Conventional channel runtime lockout. Press
Lon the TUI’s Scanner panel (orPOST /api/v1/scanner/conventional/{idx}/lockout) to skip a conventional FM channel during scan, matching the muscle memory of a Uniden / Whistler lockout button. Skipped channels show a✗marker in the channel list and are omitted frompickNextChannelrotation; pressingLagain (or POSTingunlockout) restores them. If the locked-out channel is currently dwelling, the synthetic call ends immediately (EndReasonLockout) so the operator’s intent takes effect within one IQ chunk. Lockouts are runtime-only — they don’t persist across daemon restarts, since config is the right place to permanently exclude a channel. - macOS RTL-SDR serial / manufacturer / product strings. The
pure-Go USB enumerator’s Darwin backend
(
internal/sdr/rtlsdr/usb/usb_darwin.go) now reads the IORegistry string properties viaCFStringGetCStringinstead of returning empty placeholders, sogophertrunk sdr liston macOS prints the same per-device identification (serial, manufacturer, product) that Linux and Windows have shipped since PR-10. Useful for multi-dongle hosts where the operator wants to pin a specific SDR by serial inconfig.yaml. Closes theTODO(macos-strings)flagged in the file since PR-03. - YSF integration-cc + P25 P1 grant-chain extension.
Closes the original planning roadmap — every trunked
protocol gophertrunk decodes now has an end-to-end “lights
up live trunked reception” integration test, and the P25
Phase 1 path now asserts the full status → IdentifierUpdate
→ GroupVoiceChannelGrant TSBK chain through the production
daemon + bus + supervisor + API + metrics chain (the
previous version stopped at cc.locked).
cmd/gophertrunk/integration_cc_ysf_test.goboots the daemon with synthesized 4800-baud C4FM IQ carrying back-to-back YSF FSW-bearing frames (480-dibit frame layout, FSWPattern at offset 0, zero-filled FICH + payload regions) and asserts the productionnewYSFPipeline+ supervisor + API + metrics chain recovers the lock. Same C4FM modulator + RRC pulse shaping as P25 P1 / NXDN / DMR / dPMR; the receiver’sOptions.DeviationHzslicer calibration knob now ships oninternal/radio/ysf/receiver(1800 Hz peak spec deviation per Yaesu’s C4FM TX chain).make integration-cc-ysfruns it standalone.cmd/gophertrunk/integration_cc_test.gogrows a second test,TestDaemonCCDecodesP25Phase1GrantChain, that usesccdecoder.SetTestFactoryto install a stub pipeline pumping the synthesized FSW + NID + TSBK dibit stream straight into a realphase1.ControlChannelon the first IQ chunk. Exercises everything above IQ → dibit — the factory dispatch, the band plan, the bus publication, the engine, the supervisor, the API, the metrics handler — through the production code paths, without depending on the receiver’s Mueller-Müller clock loop landing every subsequent FSW + NID + 98-dibit TSBK trellis window in one streaming pass (which it reliably does for the first lock but not for the multi-frame status → identifier → grant sequence the grant chain needs).make integration-cc-grantruns it standalone.trunking.ProtocolYSFlands in the protocol enum (string form"ysf");ParseProtocol+Validateaccept it. The ccdecoder factory map registersnewYSFPipelineforProtocolYSFso live configprotocol: ysfslots into the production hunt chain.- 30-run flakiness sweep on all three integration-cc P25 P1 / YSF / grant tests clean.
Punch-list status: all 9 protocols + 4 modulator primitives + YSF + grant chain shipped. The original roadmap that opened with “every protocol package ships a CC state machine, every trunking surface lights up the moment a grant lands, and yet the daemon never publishes its first live cc.locked event” is now fully closed — the daemon publishes cc.locked + grant on every supported protocol when synthesized IQ matching the protocol arrives on the control SDR.
- Sub-audible NRZ modulator +
make integration-cc-ltr. Closes the per-protocol “lights up live trunked reception” punch list — every trunked protocol gophertrunk decodes now has an end-to-end integration test running synthesized IQ through the production daemon + receiver chain.internal/dsp/demod/subaudible_nrz_modulator.goships the TX counterpart to the LTR receiver’s FM-demod → narrow-LPF → MM clock recovery → zero-threshold slicer chain: bit → bipolar symbol (±audioAmp) → FM modulator (phase advances byaudioAmpper sample) → IQ. The audio amplitude is tuned so the FM demod output sits comfortably inside the receiver’s LPF passband (below 300 Hz) at the 9600× lower symbol rate vs the other protocols.ltr.LockStatenow implementstrunking.LockedPayload. Eighth and final protocol with the same latent-bug class fixed (NXDN / dPMR / EDACS / Motorola / TETRA / P25 Phase 2 / MPT 1327 / LTR). LTR doesn’t have a P25-style NAC; the(Area, Repeater)pair gets packed into the NAC slot as(Area << 8) | Repeater.- The integration test synthesizes 80 back-to-back idle Status words (no gap — LTR’s 41-bit Status word stream is continuous) at 300 baud, modulates via the new sub-audible primitive, and asserts the daemon recovers the lock with the expected Area + Repeater. Warmup is all-zero (the parser’s sliding 41-bit window would otherwise commit to a spurious Sync=1 alignment from alternating-pattern warmup).
- Round-trip modulator tests cover the chain end-to-end against the FM discriminator + Kaiser LPF (100 random bits, every bit recovered exactly past the LPF group- delay warmup), phase continuity across chunked Modulate calls, constant envelope, and Reset semantics. 30-run flakiness check clean.
Punch-list status: all 9 protocols + 4 modulator primitives shipped — P25 P1 / NXDN / DMR Tier III / dPMR Mode 3 / EDACS / Motorola Type II / TETRA / P25 Phase 2 / MPT 1327 / LTR. The C4FM modulator (PR #148) drove the 4-FSK family; GFSK (PR #152) drove EDACS + Motorola; π/4-DQPSK (PR #154) drove TETRA + P25 P2; FFSK (PR #156) drove MPT 1327; sub-audible NRZ (this PR) drove LTR.
- FFSK modulator +
make integration-cc-mpt1327. First integration test to exercise audio-band FSK modulation. Lights up MPT 1327 end-to-end through the daemon’s mock-SDR + production-receiver chain.internal/dsp/demod/ffsk_modulator.goships the TX counterpart to the existing FFSK tone discriminator: bit → tone select (mark / space) → continuous-phase audio sinusoid at the tone frequency → FM modulator (phase accumulator integrates audio) → IQ.FFSKModulatorcarries both the audio-phase and the RF-phase accumulators acrossModulatecalls so long streams stay phase-continuous;ModulateFFSKis the single-shot convenience.mpt1327.LockStatenow implementstrunking.LockedPayload(LockedFrequencyHz+LockedNAC). Seventh protocol with the same latent bug fixed (NXDN / dPMR / EDACS / Motorola / TETRA / P25 Phase 2 / MPT 1327). MPT 1327 doesn’t have a P25-style NAC; the AHYC SystemID is the closest per-cell identifier and gets plumbed into the NAC slot.- The integration test synthesizes 100 back-to-back
BCH(63, 38)-encoded ALH (Aloha) codewords (the
canonical “lock me” address codeword), modulates via
the new FFSK primitive at the standard CCIR FFSK
tone pair (1200 Hz mark / 1800 Hz space) at 1200
baud, and asserts the daemon recovers the lock via
mpt1327_bch_mode: on. 30-run flakiness check clean. - Round-trip modulator tests cover the FFSK chain against the existing FM discriminator + FFSK tone discriminator (200 random bits, every bit recovered exactly past the LPF group-delay warmup), phase continuity across chunked Modulate calls, constant-envelope (|IQ| = 1 ± 1e-6), and Reset semantics.
make integration-cc-p25p2— P25 Phase 2 end-to-end lights-up check. Second protocol to use the π/4-DQPSK modulator shipped in PR #154; reuses the primitive withrotation = π/8to synthesize H-DQPSK (the π/8-shifted variant P25 Phase 2 specifies).- 6000 sym/s, α = 0.20 RRC, sps = 8 at the test’s 48 kHz
sample rate — different from TETRA’s 18000 sym/s /
α = 0.35 / sps = 4 path, but the modulator’s rotation
- sps + α parameters cover both cleanly.
p25phase2.LockStatenow implementstrunking.LockedPayload. Sixth protocol with the same latent-bug class fixed (NXDN / dPMR / EDACS / Motorola / TETRA / P25 Phase 2). P25 Phase 2’s MAC PDU header doesn’t carry a NAC equivalent — the NAC lives one layer up in the Phase 2 superframe — soLockedNACreturns 0; the supervisor uses it only as a cache key on retune, so 0 is harmless.- The P25 Phase 2 pipeline factory tunes its Gardner
ClockGainto 0.005 (same value as the TETRA factory in PR #154; the 0.03 default over-corrects on clean H-DQPSK signals and slips). - Integration test synthesizes 80 back-to-back
OpMACPTTMAC PDUs (the canonical “lock me” non-idle PDU) through the production trellis encoder (framing.EncodeP25Trellis), wraps each in a 20-dibit outbound sync, and asserts the daemon recovers the lock viap25_phase2_trellis_mode: on. 30-run flakiness check clean on first try.
- 6000 sym/s, α = 0.20 RRC, sps = 8 at the test’s 48 kHz
sample rate — different from TETRA’s 18000 sym/s /
α = 0.35 / sps = 4 path, but the modulator’s rotation
- π/4-DQPSK modulator +
make integration-cc-tetra. First integration test to exercise a non-FSK modulation family, lighting up the full TETRA TMO control-channel decode end-to-end against synthesized IQ.internal/dsp/demod/piover4_dqpsk_modulator.goships the TX counterpart to the existingPiOver4DQPSKdemodulator: dibit → raw phase delta ∈ {0, π/2, π, -π/2} → +rotation per symbol → cumulative phase → complex symbol → impulse train × sps → unit-energy RRC pulse shape → IQ. The rotation argument selects between true π/4-DQPSK (TETRA TMO, rotation = π/4) and π/8-shifted H-DQPSK (P25 Phase 2, rotation = π/8).PiOver4DQPSKModulatorcarries phase + FIR history acrossModulatecalls so long streams can be chunked.tetra.LockStatenow implementstrunking.LockedPayload(LockedFrequencyHz+LockedNAC). Fifth protocol with the same latent-bug class fixed on NXDN / dPMR / EDACS / Motorola in PRs #149 / #151 / #152 / #153. TETRA doesn’t have a P25-style NAC; the LocationArea is the closest per-cell identifier and gets plumbed into the NAC slot.- The TETRA pipeline factory tunes the Gardner clock loop down from the 0.03 default to 0.005. At 18000 sym/s the standard gain over-corrects on clean signals and slips with > 50% dibit errors; 0.005 tracks both clean synthesized IQ and noisier on-air captures within the loop’s lock-acquisition margin. Same pattern as the DMR Tier III ClockGain tweak in PR #150.
- The integration test synthesizes a full §8.3.1 SCH/HD
burst (38-dibit normal training-sequence sync +
108-dibit channel-coded SCH/HD carrying an MLE SYSINFO
PDU with a known LocationArea), modulates via the new
π/4-DQPSK primitive, and asserts the daemon recovers
the lock through the production
newTETRAPipelinewithtetra_channel_coding: on+tetra_colour_codeconfig. - Round-trip modulator tests cover dibit recovery through the existing RRC matched filter + DQPSK quadrant decoder (200 random dibits, every one recovered exactly), phase continuity across chunked Modulate calls, and Reset semantics.
- 30-run integration flakiness check clean.
make integration-cc-motorola— Motorola Type II end-to-end lights-up check. Second non-C4FM protocol to light up through the daemon; reuses the GFSK modulator shipped in PR #152 with different per-protocol framing (Motorola Type II OSW vs EDACS CCW) and a different FEC chain (per-codeword BCH(64, 16, 11) wrapping each 16-bit OSW half vs EDACS’ single BCH(40, 28, 2) over the whole CCW).- 3600-baud 2-FSK with BT = 0.5 — the SmartZone standard’s tighter-bandwidth profile vs EDACS’ 0.3. Sample rate picked at 97.2 kHz so an integer sps = 27 matches the receiver’s float computation with no rounding drift.
motorola.LockStatenow implementstrunking.LockedPayload(LockedFrequencyHz+LockedNAC). Same latent-bug class fixed on NXDN / dPMR / EDACS in PRs #149 / #151 / #152 — fourth protocol with the same shape.- The test synthesizes an
OpSystemIDExtendedOSW (carrying a SystemID announcement) throughframing.BCHEncode64_16× 2 for the two halves, sandwiches it between the 24-bit outbound sync and idle padding, and asserts the daemon recovers the lock via themotorola_bch_mode: onopt-in. - 30-run flakiness check clean.
- GFSK modulator +
make integration-cc-edacs. First non-C4FM protocol to light up end-to-end through the daemon’s mock-SDR + production-receiver chain.internal/dsp/demod/gfsk_modulator.goships the Gaussian-FSK TX counterpart to the existing GFSK demodulator: bit → bipolar symbol → impulse train × sps → unit-sum-normalised Gaussian premod filter → FM modulator (phase accumulator) → IQ.GFSKModulatoris stateful acrossModulatecalls so long streams can be chunked;ModulateGFSKis the single-shot convenience.- The receiver-side slicer at zero threshold needs no
DeviationHzcalibration knob — GFSK is symmetric around DC, and the receiver’s existing zero-threshold slicer Just Works once the modulator produces a real Gaussian-shaped FSK signal. edacs.LockStatenow implementstrunking.LockedPayload(LockedFrequencyHz+LockedNAC). Same latent-bug class as the NXDN / dPMR fixes in PRs #149 / #151 — without these methods, the supervisor’s type-assertion on cc.locked silently drops the event and/api/v1/scannernever surfacesstate=lockedfor EDACS systems.make integration-cc-edacsboots the daemon with synthesized 9600-baud GFSK IQ (BT = 0.3, ±2.4 kHz peak deviation) carrying a 24-bit outbound sync + 40-bit BCH(40, 28, 2)-encoded CmdSystemID CCW. The test enablesedacs_bch_mode: onso the FEC layer is exercised end-to-end on the recovered bits.- Round-trip tests cover the modulator against the existing GFSK demodulator (200 random bits, every bit recovered exactly), phase continuity across chunked calls, constant-envelope (|IQ| = 1 ± 1e-6), and Reset semantics. 30-run integration flakiness check clean.
make integration-cc-dpmr— dPMR Mode 3 end-to-end lights-up check. Fourth per-protocol sibling ofintegration-cc. Boots the daemon with a mock SDR replaying synthesized dPMR Mode 3 IQ (24-dibit FS3 sync- 40-dibit / 80-bit
StandingServiceStatusCSBK), and asserts the productionnewDPMRPipeline+ supervisor + API + metrics chain recovers the lock. internal/radio/dpmr/receiverpicks up the sameOptions.DeviationHzslicer-calibration knob as the P25 P1 / NXDN / DMR receivers (PRs #148 / #149 / #150). The ccdecoder’snewDPMRPipelinepasses 900 Hz — half the P25 / DMR / YSF deviation, matching the 6.25 kHz channel spacing dPMR targets.dpmr.LockStatenow implementstrunking.LockedPayload(LockedFrequencyHz+LockedNAC). dPMR doesn’t have a P25-style NAC; the low 16 bits of SystemID are the closest per-cell identifier and get plumbed into the NAC slot. Same latent-bug class as the NXDN fix in PR #149 — without these methods, the supervisor’s type-assertion on cc.locked silently drops the event and/api/v1/scannernever surfacesstate=locked.- The C4FM modulator from PR #148 handles dPMR’s
half-rate 2400 sym/s modulation directly via the
spsparameter (20 instead of P25/DMR/NXDN’s 10 at the same sample rate). No DSP changes needed. - 30-run flakiness check clean on first try — the lower symbol rate + lower deviation gives the MM clock loop a comfortable margin without needing the ClockGain tweak DMR needed in PR #150.
- 40-dibit / 80-bit
make integration-cc-dmr— DMR Tier III end-to-end lights-up check. Third per-protocol sibling ofintegration-cc. Boots the daemon with a mock SDR replaying a fully-synthesized 132-dibit DMR Tier III burst (49-dibit first-half payload + 5-dibit slot-type + 24-dibit BS-Data sync + 5-dibit slot-type + 49-dibit second-half payload, with the payload carrying an Aloha CSBK through BPTC(196, 96)), and asserts the productionnewDMRTier3Pipeline+ supervisor + API + metrics chain recovers the lock.internal/radio/dmr/receiverpicks up the sameOptions.DeviationHzslicer-calibration knob shipped on the P25 P1 + NXDN receivers (PRs #148 + #149). The ccdecoder’snewDMRTier3Pipelinepasses 1944 Hz — the ETSI TS 102 361-1 §6.3 spec deviation.- The same factory also bumps
ClockGaindown to 0.025 (from the 0.05 default). DMR’s 1944 Hz deviation is ~8% larger per-sample phase excursion than P25 P1’s 1800 Hz; the standard MM gain slips on the harder symbol transitions inside random BPTC payloads. The lower gain tracks cleanly on synthesized IQ and stays well within the loop’s noise margin for live captures. - The DMR Tier III
LockStatealready implementedtrunking.LockedPayload, so no per-protocol wiring bug surfaced here (unlike NXDN in PR #149). - The C4FM modulator from PR #148 handles DMR’s 4800-baud 4-FSK / α = 0.20 modulation identically to P25 P1 + NXDN; the only per-protocol differences are the deviation (1944 Hz), the burst framing (132-dibit TDMA bursts vs P25’s continuous stream), and the channel coding (BPTC(196, 96) + slot-type Hamming(20, 8) vs P25’s trellis-encoded TSBK).
- 30-run flakiness check clean. The flakiness fix was a longer (800-dibit) warmup prefix so the lower-gain MM loop has time to fully converge before the first burst’s random payload tests it.
make integration-cc-nxdn— NXDN end-to-end lights-up check. First sibling target ofintegration-cccovering a second protocol end-to-end. Boots the daemon with a mock SDR replaying a fully-synthesized NXDN-TS-1-A §4.6 RCCH outbound frame (FSW + LICH + 150-dibit CAC carrying a SITE_INFO message through the §4.5.1.1 spec FEC chain shipped in PR #144), and asserts the productionnewNXDNPipeline+nxdn_viterbi_mode: specrecover the lock and surface it through the bus + supervisor + API + metrics.internal/radio/nxdn/receivergains the sameOptions.DeviationHzcalibration knob the P25 Phase 1 receiver picked up in PR #148. The ccdecoder’snewNXDNPipelinefactory passes the spec 1800 Hz value so live captures slice correctly out of the box.nxdn.LockStatenow implementstrunking.LockedPayload(LockedFrequencyHz+LockedNACmethods). NXDN doesn’t have a P25-style NAC; the SiteID is the closest per-cell identifier and is plumbed into the NAC slot. Without this, cc.locked events fired correctly but the cchunt supervisor’s state machine silently dropped them on the type-assertion check and/api/v1/scannernever surfacedstate=lockedfor NXDN systems.- The C4FM modulator from PR #148 carries straight over — NXDN’s 9600-baud 4-FSK / α = 0.20 / 1800 Hz deviation matches P25 Phase 1’s modulation params exactly. The only differences are framing (192-dibit / 80 ms frames, 8-dibit FSW vs P25’s 24-dibit FSW, LICH + CAC vs NID + TSBK) and the channel-coding chain above the demod — both of which were already wired up by earlier PRs. 20-run flakiness check clean.
- C4FM modulator + RRC pulse shaping + receiver-side
slicer calibration. Closes the last stub in the
make integration-ccchain. The IQ → dibit demodulation step is now exercised end-to-end against real synthesized IQ (no factory stub, no dibit injection).internal/dsp/demod/c4fm_modulator.goimplements the full TX chain: dibit → ±1/±3 symbol → impulse train × sps → RRC pulse-shape filter (unit-energy, matches the receiver’s RRC matched filter) → FM modulator (phase accumulator) → IQ.C4FMModulatoris stateful across Modulate calls so long streams can be chunked; theModulateC4FMconvenience wraps a single-shot call.internal/radio/p25/phase1/receivergainsOptions.DeviationHz— when set the slicer thresholds are calibrated against the FM-discriminator output level (2π · DeviationHz / SampleRateHzat symbol ±3) instead of the legacy hardcodedslicerScale = 1.0. The default (no DeviationHz) preserves the existing fixture behaviour for back-compat. The ccdecoder’snewP25Phase1Pipelinefactory hardcodes 1800 Hz per TIA-102.BAAA-A so live captures slice correctly out of the box; a future revision can plumb this through per-system YAML if non-standard deviation comes up.cmd/gophertrunk/integration_cc_test.gois rewritten to feed real C4FM-modulated IQ through the productionnewP25Phase1Pipelineinstead of stubbing the factory. The dibit stream is unchanged (FSW + NID + trellis- encoded TSBK), but it’s now passed throughdemod.ModulateC4FM→ u8-IQ file → mock SDR →phase1/receiver→phase1.ControlChannel.Process→cc.locked. 20-run flakiness check clean. Tests cover the modulator round-trip against the receiver chain (200 random dibits with every symbol level represented, all recover correctly), phase continuity across chunked Modulate calls, constant- envelope (|IQ| = 1 ± 1e-6) sanity, and the dibit→symbol mapping pinned as the inverse ofphase1.SymbolToDibit. The earlierccdecoder.SetTestFactorytest hook stays exported for any future protocol-pipeline integration tests that need to inject behaviour above the demod.
make integration-cc— the “lights up live trunked reception” milestone. Closes Workstream A of the original plan. The new target boots the wired daemon (mock SDR + cchunt supervisor + ccdecoder + API + metrics) and asserts the full chain above the IQ → dibit demod recovers a P25 Phase 1 lock end-to-end:- daemon construction
- cchunt supervisor publishing
KindHuntProgress - ccdecoder factory dispatch + pipeline construction
pipeline.Processinvoked on every IQ chunkphase1.ControlChannel.Processdriving the state machine from FSW + NID + TSBK dibit fixtures- state machine emitting
cc.lockedon the bus - supervisor consuming
cc.locked→state=locked /api/v1/scannerreflecting the lockgophertrunk_control_channel_locked{system=…}= 1gophertrunk_events_total{kind="cc.locked"}= 1 The one chain step the test stubs is C4FM IQ→dibit demodulation (RRC pulse shaping + continuous-phase integration are a non-trivial DSP layer in their own right). The receiver layer is covered byinternal/radio/p25/phase1/receiver’s unit tests; this PR validates everything above it.
Plumbing changes:
ccdecoder.SetTestFactoryis a new exported tests-only hook that replaces the registered pipeline factory for a single protocol and returns a restore function. Production code must not call it.ccdecoder.Decodernow subscribes to the events bus atNewtime rather than insideRun. That removes a race where the cchunt supervisor could publishKindHuntProgressbefore the decoder’s subscription landed, causing the first lock attempt to silently miss the connector and the test to fail intermittently. The change also makes the production daemon’s startup deterministic — no more “first hunt round drops on the floor” timing dependency.
A future PR can land a proper C4FM modulator + RRC shaping primitive in
internal/dsp/, swap the factory stub for real synthesized IQ, and exercise the demod layer in the same integration test.- Motorola Type II BCH(64, 16, 11) wired through the
connector. Closes the last unfinished FEC opt-in in the
TETRA / LTR / P25 P2 / NXDN / EDACS / MPT 1327 / Motorola
family. The BCH layer existed on
motorola.ControlChannelfor a while (SetBCHMode(BCHOn)reads two 64-bit codewords after sync, decodes each viaframing.BCHDecode64_16, and reassembles the 32-bit OSW from the two recovered 16-bit halves with single- through 11-bit-error correction per codeword); this PR threads it through the same per-system YAML pipeline every other protocol uses:trunking.SystemgainsMotorolaBCHMode string;config.SystemConfigexposes it asmotorola_bch_mode(""/"off"/"on").motorola.ParseBCHMode+motorola.ControlChannel.BCHMode()mirror the accessors on every other FEC-opt-in protocol.newMotorolaPipelinecallsSetBCHModebefore any sample flows; empty string preserves the legacy 32-bit raw-OSW path for synthesized-fixture tests.api.SystemDTO+client.SystemDTOcarry the field asomitemptyJSON; the TUI Settings panel renders abch: on/bch: offrow for Motorola systems.- README’s FEC opt-ins table gains a Motorola row. With this PR, every protocol whose ControlChannel exposes a tunable on-air FEC layer is now connector-configurable from per-system YAML.
- Reference spec PDFs consolidated under
docs/specs/. The NXDN-TS-1-A and ETSI EN 300 392-2 PDFs that drive the on-air FEC implementations were previously sitting at the repo root with vendor-supplied filenames; the M/A-COM LBI-38463C “EDACS System Manager Supervisor’s Guide” uploaded as a candidate EDACS air-interface reference was only in the chat. All three now live underdocs/specs/with normalised filenames (nxdn-ts-1-a-v1.3.pdf,etsi-en-300-392-2-v3.8.1.pdf,lbi-38463c-edacs-system-manager.pdf) and adocs/specs/README.mdthat maps each PDF to the code paths it backs (NXDN → §4.5 channel coding; TETRA → §8.2/§8.3.1 chain) and explains why the LBI is a negative reference — it documents the system-admin workstation UI, not the air interface, so future readers looking for an EDACS spec know to skip it and pursue LBI-39031 / LBI-39154 / LBI-38894 instead.git mvpreserves history for the two previously-tracked PDFs. - EDACS FEC documentation correction. Earlier package
docstrings + README bullets called out an “interleaved
Reed-Solomon-derived FEC layer above the BCH” on the
EDACS CCW as missing / a future PR. Per the canonical
open reference (
lwvmobile/edacs-fm) and a careful read of the existinginternal/radio/framing/bch_edacs.goimplementation, no such outer layer exists in Standard EDACS — BCH(40, 28, 2) per CCW is the only on-wire FEC, and it’s already shipping behindedacs_bch_mode: on. Each affected docstring is updated to say so explicitly, and the historical “Recently shipped” entries that named the imaginary RS layer as a follow-up gain a corrective footnote. No code logic changes — only documentation. - NXDN CAC spec-correct interleave + puncture per
NXDN-TS-1-A rev 1.3 §4.5.1.1. Closes the “blocked on
spec data” gap on the previous round — the user uploaded
the NXDN-TS-1-A spec (now in
docs/specs/nxdn-ts-1-a-v1.3.pdf) and the full outbound CAC channel coding chain landed end-to-end.internal/radio/nxdn/cac_channel.goaddsEncodeCACChannelDecodeCACChannelimplementing the spec’s six-stage chain: 155 info bits (8 SR + 144 L3 Data + 3 Null) ‖ 16-bit CRC-CCITT (poly0x1021, init0xFFFF, no XOR, evaluated bit-level since 155 isn’t byte-aligned) ‖ 4 zero tail bits → K=5 R=½ convolutional encode (350 bits) → puncture matrix1111111 / 1011101drops 50 pre-puncture positions (300 bits) → 25×12 block interleaver (write rows, read columns) → 300 channel bits = 150 dibits on air.
- The puncture positions are derived from the spec’s
matrix at package-init time so a future spec revision
can patch the matrix in one place. An
init()invariant panics if the matrix, encoder length, or channel-bit arithmetic ever drift apart. ViterbiModegains a newViterbiSpecvalue; the Process adapter underViterbiSpecslices 158 post-sync dibits (8 LICH + 150 CAC) per the §4.6 RCCH outbound layout (FSW + LICH + CAC + E + Post = 384 bits / 192 dibits), runs the full decode chain, and forwards the recovered L3 prefix into the existingParseCAC. The spec’s outer CRC has already validated the 155-bit info block, so the inner-CRC sentinelParseCACenforces is re-synthesized locally over the recovered L3 prefix.ParseViterbiModerecognises"spec"(case-insensitive, whitespace tolerated) so the existingnxdn_viterbi_modeYAML key +ccdecoderconnector + TUI Settings panel all light up without further plumbing.- The legacy
ViterbiOnpath (8 LICH + 32 SACCH + 92 encoded CAC dibits) is preserved for back-compat with the older MMDVMHost / DSDcc fixtures; existing tests keep passing. Tests cover the framing primitives (round-trip across four seeds, single-bit error correction, heavy-corruption CRC catch, wrong-size rejection, puncture-matrix algebra, interleaver bijection, byte-aligned CRC sanity against the existingframing.CRCCCITT) plus the Process integration (spec-encoded SITE_INFO recoversKindCCLockedwith the expected SiteID / SystemID; heavily-corrupted spec frames drop silently).
-
TUI Settings panel + README FEC opt-ins reference. The 11th TUI panel (
Tabpast Scanner) renders each configured system with a one-line summary of its FEC opt-in state across every protocol that has a public-spec FEC chain — TETRA channel coding, LTR FCS + Manchester, P25 Phase 2 trellis, NXDN Viterbi, EDACS + MPT 1327 BCH. The panel reads the new opt-in fields off/api/v1/systems’ per-system DTO; the APISystemDTOwas extended to expose every opt-in flag as anomitemptyJSON value, and the client mirror picks them up without further plumbing.The panel is read-only; the bottom-line hint says “Edit config.yaml + restart daemon to change”, which matches the existing wiring (opt-ins flow
SystemConfig→trunking.System→ccdecoder.PipelineFactoryat construction / on eachHuntProgressretune). Runtime mutation is a future follow-up that requires a PATCH endpoint + daemon-side reconfig of active pipelines.README gained an “FEC opt-ins” section with a table covering every YAML key, its default behaviour, and what the on-path unlocks. Each protocol’s
ControlChannelalso picked up matching getters (tetra.ChannelCoding()/ExpectedChannel()/ColourCode(),ltr.FCSMode()/ManchesterMode(),p25phase2.TrellisMode(),nxdn.ViterbiMode(),edacs.BCHMode(),mpt1327.BCHMode()) — the TUI uses them indirectly through the DTO; tests + observability code use them directly. ccdecoderconnector threads the remaining per-protocol FEC opt-ins from per-system config. Closes out the connector-side FEC wiring for every protocol whose ControlChannel exposes a tunable on-air FEC layer. Same pattern as PRs #141 (TETRA channel coding) and #142 (LTR FCS + Manchester) — operators set one YAML key per protocol and the matching pipeline factory turns the FEC layer on automatically:p25_phase2_trellis_mode: on→SetTrellisMode(TrellisOn)on the 4-state ½-rate trellis decoder over P25 Phase 2 MAC PDUs (146 channel dibits → 72 info dibits per TIA-102.AABF).nxdn_viterbi_mode: on→SetViterbiMode(ViterbiOn)on the K=5 ½-rate Viterbi decoder over the NXDN CAC region (92 dibits → 88 info bits + 4 tail zeros per MMDVMHost’s NXDNConvolution).edacs_bch_mode: on→SetBCHMode(BCHOn)on the BCH(40, 28, 2) decoder over the EDACS CCW (generator 0x1539, single/double-bit correction).mpt1327_bch_mode: on→SetBCHMode(BCHOn)on the BCH(63, 38) decoder over the MPT 1327 codeword (64-bit on-wire → 38 info bits + 26 parity). Each protocol also gains aParseXxxModehelper +XxxMode()accessor mirroring the TETRA / LTR pattern shipped in PR #141 / #142 so tests + observability code can introspect configured state. Empty strings preserve the legacy raw-bit path across all four protocols so existing synthesized-fixture tests stay green. Unknown values warn-log and fall back to the off default rather than failing the retune.
With this PR, the only connector wiring that remains is protocol-by-protocol on-air interleaver / puncture layers for protocols whose public specs don’t fully document them. NXDN CAC interleave / puncture landed as a separate follow-up (see the NXDN CAC entry above). EDACS Standard has no outer FEC layer above the BCH — earlier README claims of an “interleaved Reed-Solomon-derived FEC layer” on the CCW were a documentation error; per the canonical open reference (
lwvmobile/edacs-fm) the BCH(40, 28, 2) is the only on-wire FEC, and that path already ships.ccdecoderconnector threads LTR FCS + Manchester modes from per-system config. Same pattern as the TETRA wiring in PR #141 — operators setltr_fcs_mode+ltr_manchester_modeonce inconfig.yamland thenewLTRPipelinefactory callsltr.ControlChannel.SetFCSMode/SetManchesterModebefore any sample flows. Bothltr.ControlChannelprimitives have shipped for a while (CRC-7 FCS check against sdrtrunk’s CRCLTR.java layout, Manchester decode with strict / soft variants); this PR flips them on under config control.trunking.SystemgainsLTRFCSMode string+LTRManchesterMode string;config.SystemConfigexposes them asltr_fcs_mode(recognises"off"/"on") +ltr_manchester_mode(recognises"off"/"nrz"/"strict"/"soft", all case-insensitive with whitespace tolerated).ltr.ParseFCSMode+ltr.ParseManchesterModemap the YAML string into the typed mode; unknown values warn-log and fall back to the legacy off / NRZ default rather than failing the retune.ltr.ControlChannel.FCSMode+ManchesterModeaccessors mirror the TETRA pattern so tests + observability code can introspect configured state without poking at unexported fields.- Empty strings preserve the legacy
FCSOff+ManchesterOffraw-NRZ path so existing synthesized-fixture tests stay green. Live captures of sub-audible LTR signaling typically needltr_manchester_mode: soft+ltr_fcs_mode: onto pass the CRC. Tests cover the config-string parsers across every recognised value (plus a misconfigured-input case), the factory applying both modes when the System carries non-empty strings, and the factory preserving the legacy modes when both strings are empty. The connector now configures every protocol whose control-channel state machine has a tunable on-air FEC layer (TETRA channel coding, LTR FCS + Manchester); per-protocol FEC wiring for NXDN CAC / EDACS CCW / P25 Phase 2 trellis remains the next code work, gated on the public-references question (NXDN CAC interleave / puncture isn’t documented in the public spec).
ccdecoderconnector threads TETRA channel coding from per-system config. Closes the last gap between the daemon’s YAML and the §8.3.1 type-5 → type-1 decoder shipped in PR #140 — operators set the cell’s extended colour code + signaling channel once inconfig.yamland thenewTETRAPipelinefactory flipstetra.ControlChannel.SetChannelCoding(ChannelCodingOn)automatically on every retune.trunking.SystemgainsTETRAColourCode uint32(low 30 bits of the §8.2.5 extended colour code, bits 30..31 silently ignored) andTETRAChannel string(the config-side name for the logical channel that lives in each burst window).config.SystemConfigexposes those astetra_colour_codetetra_channelYAML keys;cmd/gophertrunk/daemon.goforwards them intotrunking.Systemon construction.
ccdecoder.PipelineOptionscarries the fulltrunking.Systemso per-protocol factories can read protocol-specific config without a new field per protocol.tetra.ParseChannelTypemaps the YAML string ("sch/hd" | "sch/f" | "sch/hu" | "bsch" | "aach", case-insensitive,"/"and"_"both accepted, empty defaults tosch/hd) into atetra.ChannelType; unknown values fall back to SCH/HD with a warn-level log entry.tetra.ControlChannel.ChannelCoding/ExpectedChannel/ColourCodeaccessors let tests + observability code introspect the configured state without poking at unexported fields.- Zero
TETRAColourCodepreserves the legacyChannelCodingOffraw-dibit path so existing synthesized-fixture tests stay green. Tests cover the config-string → ChannelType parser across every recognised value (plus a misconfigured-input warning case), the factory turning channel coding on with the right colour code + channel under a populated System, and the factory leaving channel coding off when the colour code is left at the zero default. The remaining work toward “lights up live trunked reception” is now protocol-by-protocol FEC wiring across the other 9 protocols, not connector plumbing.
- TETRA
SetChannelCoding(ChannelCodingOn)opt-in wires per-channel FEC decode intoProcess. Lights up the full ETSI EN 300 392-2 §8.3.1 type-5 → type-1 chain (descramble + deinterleave + depuncture + Viterbi + CRC-16 verify + tail strip) on thetetra.ControlChannelProcess adapter so live IQ captures — not just synthesized type-1 fixtures — can drivecc.locked/ Grant events. New API mirrors the BCH wirings on MPT 1327 / EDACS / Motorola:SetChannelCoding(ChannelCodingOff | ChannelCodingOn)— default off (legacy 48-dibit raw path); on enables the full FEC chain.SetExpectedChannel(ChannelSCHHD | ChannelSCHF | ChannelSCHHU | ChannelBSCH | ChannelAACH)— picks which logical channel lives in each burst window under the on path. DefaultChannelSCHHD.SetColourCode(uint32)— 30-bit extended colour code seeding the scrambler (low 30 bits; masked to0x3FFFFFFF). Ignored by BSCH per §8.2.5.2. UnderChannelCodingOnthe adapter slices the channel-appropriate dibit window (108 for SCH/HD, 216 for SCH/F, 84 for SCH/HU, 60 for BSCH, 15 for AACH), routes through the matchingDecodeSCHHD/DecodeSCHF/DecodeSCHHU/DecodeBSCH/DecodeAACHhelper shipped in PR #139, and silently drops frames whose CRC fails. Tests round-trip a realMLE SYSINFOPDU through SCH/HD →KindCCLocked, aCMCE D-CONNECTPDU through SCH/F →KindGrant, plus heavy-corruption rejection (30 adjacent bit flips) and wrong-colour-code rejection. Wiring this into theccdecoderconnector so per-system config (colour code, expected channel) flows fromtrunking.Systeminto the live decoder is the next PR.
- TETRA per-channel encode/decode helpers in
tetra/. Composes the framing primitives shipped in PRs #137 and #138 (RCPC + (30,14) RM + (K,a) block interleaver + scrambler + the existing CRC-16 CCITT) into the full type-1 → type-5 encode chain and its inverse per ETSI EN 300 392-2 §8.3.1 for every standard π/4-DQPSK signaling channel:EncodeSCHHD/DecodeSCHHD— 124 ↔ 216 bits (§8.3.1.4.1, also covers BNCH + STCH)EncodeSCHF/DecodeSCHF— 268 ↔ 432 bits (§8.3.1.4.5)EncodeSCHHU/DecodeSCHHU— 92 ↔ 168 bits (§8.3.1.4.3)EncodeBSCH/DecodeBSCH— 60 ↔ 120 bits, colour code fixed at 0 per §8.2.5.2 (§8.3.1.2)EncodeAACH/DecodeAACH— 14 ↔ 30 bits, simpler chain (RM + scramble only, no RCPC or interleave per §8.3.1.1) Tests round-trip every channel cleanly across multiple colour codes, confirm CRC-fail detection on heavily- corrupted streams, single-bit-error correction by the Viterbi inner decoder under R=2/3 puncturing, wrong- colour-code failure, and wrong-input-size rejection. The CRC-16 used in §8.2.3.3 is the spec’s(K1+16, K1)block code — equivalent to CRC-CCITT withinit = 0xFFFF,final XOR = 0xFFFF, processed bit-level for the non-byte-aligned K1 values TETRA uses. Wiring these helpers intotetra.ControlChannel.Process(with the burst-position discrimination from EN 300 392-2 §9 to pick which channel decode runs per slot) is the next PR.
- TETRA scrambler + (K, a) block-interleaver primitives in
framing/. Closes the remaining framing-layer gap before
the full TETRA channel-decode chain can be wired together.
framing/scramble_tetra.go— 32-tap LFSR scrambler per ETSI EN 300 392-2 §8.2.5 with connection polynomialc(x) = 1 + X + X² + X⁴ + X⁵ + X⁷ + X⁸ + X¹⁰ + X¹¹ + X¹² + X¹⁶ + X²² + X²³ + X²⁶ + X³²(tap mask 0x82608EDB). Seeded by the 30-bit extended colour code (set 0 for BSCH / BSCH-Q per §8.2.5.2). Single XOR is symmetric soScrambleTetraandDescrambleTetraare the same operation aliased for call-site readability.framing/interleave_tetra.go—(K, a)block interleaver per §8.2.4.1 with the formulab₄(k) = b₃(i)wherek = 1 + ((a × i) mod K). Per-channel constants (InterleaveK*,InterleaveA*) cover BSCH (120, 11), SCH/HD/BNCH/STCH (216, 101), SCH/HU (168, 13), SCH/F (432, 103) per §8.3.1. Together with the K=5 R=1/4 RCPC mother code + four puncturing schemes (PR #137) and the existing CRC-16 CCITT helper, this completes the framing primitives needed for end-to-end π/4-DQPSK signaling-channel decode. Tests cover symmetric XOR for the scrambler across 4 colour-code values, BSCH initial-state output prediction, sequence-balance entropy sanity, 64 random round-trips, and per-channel interleaver round-trip / permutation / spec-formula checks for all four (K, a) constants. Wiring all the primitives together intotetra.ControlChannel.Processis the next PR.
- TETRA signaling-channel RCPC + (30,14) RM primitives in
framing/. Adds the K=5 R=1/4 16-state convolutional mother
code and the four puncturing schemes TETRA uses on every
π/4-DQPSK signaling channel (BSCH, SCH/HD, BNCH, STCH,
SCH/HU, SCH/F), plus the shortened (30,14) Reed-Muller block
code used by AACH. Per ETSI EN 300 392-2 §8.2.3.1 / .2 —
distinct from the K=5 R=1/3 speech-traffic-channel code in
PR #135 (EN 300 395-2 §5.4.3): same 16-state structure but
four generator polynomials and a different puncturing
table family. Generator polynomials:
G₁(D) = 1+D+D⁴,G₂(D) = 1+D²+D³+D⁴,G₃(D) = 1+D+D²+D⁴,G₄(D) = 1+D+D³+D⁴. Puncturing schemes shipped: rate-2/3 (P=(1,2,5), used by all standard signaling channels), rate-1/3 (stronger protection, P=(1,2,3,5,6,7)), plus rate-292/432 and rate-148/432 (special long-block patterns with index- shift helpers). The (30,14) RM code uses the spec’s 14×16 parity matrix from §8.2.3.2 and is systematic in the first 14 bits. Tests cover round-trip on clean channels for both rates 2/3 and 1/3, single-bit error correction at the mother-code and punctured layers, encoder impulse-response sanity against the four generator polynomials, all 30 single-bit error positions on the RM code, parity-matrix-row consistency, and index-shift monotonicity for the special rates. The TETRAControlChanneladapter wiring (depuncture + Viterbi + CRC-16 strip → ParsePDU per channel type) is the follow-up PR. - MPT 1327 Op field extension. Adds the spec’s 10-bit Op
field (between Ident and Function) to
mpt1327.Codeword, closing the documented follow-up from PR #129. New 48-bit helpers —AssembleCodeword48/ParseCodeword48/CodewordFromBits48/CodewordBits48— operate on the full information set (Type + Prefix + Ident + Op + Function = 48 bits, MSB-first per field). The legacy 38-bitAssembleCodeword/ParseCodeword/CodewordFromBits/CodewordBitsstay back-compat: they silently drop Op on encode and leave it at zero on decode, so existing fixtures + tests that pre-date the Op field keep working byte-identically. The BCH wiring inprocess.gonow routes throughCodewordFromBits48so underSetBCHMode(BCHOn)the recovered codeword carries all 48 information bits, surfacing the full spec layout to downstreamIngest. Tests cover 48-bit round-trip preserving Op, legacy 38-bit round-trip dropping Op, reject-wrong-length error paths, the 10-bit Op mask preventing overflow into Ident, and a BCHOn end-to-end round-trip that verifies a non-zero Op survives encode → BCH-protect → decode → CCW recovery. - ClockGardner wired into the ccdecoder connector for the
π/4-DQPSK pipelines. The
newP25Phase2PipelineandnewTETRAPipelinefactories ininternal/scanner/ccdecoder/pipelines.gonow passClockMode: ClockGardnerinto the receiver constructor, so every live SDR retune through the connector runs symbol recovery via the Gardner timing-recovery loop landed in PR #128 + threaded into the receivers in PR #130. TheClockNaivepath stays available for in-package receiver-level tests that synthesize sample-aligned IQ fixtures. Other pipelines (P25 Phase 1, DMR, NXDN, EDACS, etc.) are unaffected — they use 4FSK / GFSK / FFSK demods where the existing Mueller-Müller path already handles symbol-time recovery. Existing factory tests continue to pass; the change is purely additive at the connector layer. - TETRA RCPC primitive in framing/. New shared
framing/rcpc_tetra.goadds the K=5 ½-rate→1/3-rate 16-state convolutional mother code plus puncturing / depuncturing helpers per ETSI EN 300 395-2 §5.4.3. Generator polynomialsG₁(D) = 1 + D + D² + D³ + D⁴(= 0x1F),G₂(D) = 1 + D + D³ + D⁴(= 0x1B),G₃(D) = 1 + D² + D⁴(= 0x15) — distinct from the K=5 R=½ code inviterbi_k5.go(NXDN / YSF), so this is a separate primitive with the same 16-state structure but three outputs per input. Includes spec-verbatim puncturing tables for the three rates TETRA’s normal + stealing-mode speech traffic channels use: rate-8/12 (= 2/3) for class-1 bits (P = (1, 2, 4), Period = 6, §5.5.2.1), rate-8/18 for class-2 bits in normal traffic (P = (1..5, 7, 8, 10, 11), Period = 12, §5.5.2.2), and rate-8/17 for class-2 bits under frame-stealing (17-element P, Period = 24, §5.6.2.1). The mother-codeDecodeRCPCTetraMotheris a 16-state hard-decision Viterbi; depunctured positions use the sameDepunctureMarksentinel as the K=5 R=½ code so callers can mix the two via a single decoder pattern. Tests cover mother-code round-trip + single-bit correction, encoder impulse-response sanity against the three generator polynomials, round-trips for all three puncturing schemes, single-bit-error correction over a punctured rate-2/3 channel, and a schedule-sanity check asserting the puncturing tables are strictly increasing and bounded by their Period. Wiring this primitive into the TETRAControlChannel.Processadapter (sliced 432-bit type-3 → type-2 stream per §5.5 / §5.6) is the documented follow-up. - LTR
SetFCSMode(FCSOn)opt-in. Wires theframing.CRC7LTRprimitive from PR #131 into the LTRControlChannel.Ingestpath. Under FCSOn, Ingest computes the CRC-7 over a 24-bit message vector derived from Status fields (per DSheirer/sdrtrunk’s CRCLTR.java layout: 1-bit Group / F-bit as sdrtrunk’s “Area”, then Channel/Home/ GroupID/Free), compares it to the low 7 bits ofStatus.FCS, and drops the frame on mismatch.ComputeStatusFCSis exported so test fixtures + future encoders can populate the trailer correctly. The 5-bit gophertrunkStatus.Areafield stays as opaque metadata for the multi-system filter (a different layer than the CRC-protected message); under this wiring the gophertrunk Group F-bit is the canonical sdrtrunk “Area” bit. Tests cover valid CRCs accepted, corrupted CRCs dropped, corrupted message fields dropped, FCSOff bypass preserved, default mode, and CRC-changes-with- the-Group-bit sanity. Doesn’t yet resolve the broader layout disagreement between sdrtrunk’s 7-bit CRC reading and gophertrunk’s 12-bitStatus.FCSfield (only the low 7 bits are CRC-protected in this wiring) — that’s a documented follow-up. - EDACS
SetBCHMode(BCHOn)opt-in. Wires theBCHEncodeEDACS/BCHDecodeEDACSframing primitive from PR #132 into the EDACSControlChannel.Processadapter viaSetBCHMode(BCHOff | BCHOn). Same opt-in shape as the MPT 1327 wiring (PR #129) and Motorola’s pre-existingSetBCHMode. Under BCHOn the adapter slices 40-bit on-wire codewords, runs the BCH(40, 28, 2) validation + single/double-bit correction over each slice, then re-encodes the corrected 28-bit info into a 40-bit wire word that the existingCCWFromBitsparser interprets. Uncorrectable codewords (≥ 3 bit errors in unfavourable positions) drop the frame. Under BCHOn the effective CCW model carries Command (4) + Status (4) + Address (16) + LCN (4 high bits, position 12..15) = 28 info bits; the legacy struct’s LCN bit 0 and Aux (11 bits) become BCH parity, not data. Tests cover BCHOn round-trip (an encoded GroupVoiceGrant publishes a Grant with the right Address + LCN), single-bit error correction, double-bit error correction (BCH(40, 28, 2)’s full t=2 capability), triple-bit error rejection, and default-mode regression. - EDACS BCH(40, 28, 2) primitive in framing/. New shared
framing/bch_edacs.goaddsBCHEncodeEDACS/BCHDecodeEDACSfor the EDACS Standard control-channel word check. Parameters confirmed from lwvmobile/edacs-fm’sbch3.h(the most-cited public reference for EDACS channel coding): shortened BCH(40, 28, 2) derived from BCH(63, 51, 2) over GF(2^6) with primitive polynomial x^6 + x + 1. Generator polynomialg(x) = m₁(x) · m₃(x) = x^12 + x^10 + x^8 + x^5 + x^4 + x^3 + 1 = 0x1539, designed minimum distance d = 5, corrects up to t = 2 bit errors per codeword. The decoder precomputes a 40-entry single-bit- error syndrome table at package init, then handles single-bit corrections via direct lookup and double-bit corrections by iterating the 780 ordered pairs. Tests cover round-trip cleanly across constants + 1024 random info values, single-bit correction across all 40 positions, double-bit correction across all (40 choose 2) = 780 pairs, triple-bit error rejection (> 95% detected / mis-corrected), syndrome-table uniqueness + bit-width sanity, and encoded-codeword self-syndrome zero check. Not yet wired into the EDACSControlChanneladapter; the existing 40-bit CCW struct needs cross-checking against this layout — documented follow-up. - LTR Standard CRC-7 primitive in framing/. New shared
framing/crc_ltr.goaddsCRC7LTR/VerifyCRC7LTRfor the LTR Standard message check (polynomial 0xFD, initial fill 0x00) per DSheirer/sdrtrunk’sedac/CRCLTR.java. The 24-entry syndrome lookup table covers the four fields LTR protects (Area, Channel, Home, Group, Free — 24 bits total), with direction-aware verification: OSW (outbound) frames must match the calculated checksum as-is; ISW (inbound) frames carry the bit-inverted checksum. The primitive isn’t yet wired into the LTRControlChanneladapter because the bit layout sdrtrunk documents (1-bit Area, 5-bit Channel) disagrees with the GopherTrunkStatusstruct (5-bit Area, 4-bit Channel) — reconciling the two LTR Standard interpretations is the documented follow-up. Tests cover zero-message zero-checksum, single-bit syndrome matches, 256 random-message round-trips, single-bit-error detection across all 24 positions, ISW checksum inversion, and table-uniqueness / 7-bit-bound sanity. - Gardner clock recovery threaded into the P25 Phase 2 +
TETRA receivers. Each π/4-DQPSK receiver gains an
Options.ClockMode(ClockNaivedefault,ClockGardneropt-in) that swaps the naive every-sps-th-sample decimation for thesync.Gardnertiming-recovery loop landed in PR #128. The Gardner loop manages its own cross-call tail state, so chunked streams converge once rather than per chunk;Reset()clears the loop state alongside the rest of the receiver. The existing test fixtures (which assume fixed sample alignment) keep passing under the defaultClockNaive. New tests confirm the Gardner path produces valid in-range dibits, the loop is constructed only when requested, andReset()restarts the dibit-base counter. The ccdecoder connector now wires both pipelines withClockMode: ClockGardnerso every live SDR retune throughnewP25Phase2Pipeline/newTETRAPipelineruns Gardner symbol recovery automatically. - MPT 1327
SetBCHMode(BCHOn)opt-in. Wires theBCHEncodeMPT1327/BCHDecodeMPT1327framing primitive into the MPT 1327ControlChannel.Processadapter. When on, the adapter slices 64-bit on-wire codewords (instead of the default 38-bit pre-stripped info windows), runs BCH(64,48,2) decode + single-bit error correction, then extracts the 38 info bits the existingCodewordstruct models (Type + Prefix + Ident + Function, with the spec’s 10-bit Op field between Ident and Function dropped — the struct doesn’t yet model it). The alignment search picks the first 64-bit window that BCH-passes, which is much more selective than the 38-bit “recognised opcode” search BCHOff uses, so live-air captures whose first few codewords carry single-bit errors still synchronise. Tests cover BCHOn round-trip (an Aloha → GoToChannel stream produces cc.locked + Grant), single-bit error correction (one flipped bit per codeword still locks), uncorrectable-codeword rejection (two-bit flips drop the frame), and default-mode preservation. - MPT 1327 BCH(64,48) primitive in framing/. New shared
framing/bch_mpt1327.goaddsBCHEncodeMPT1327/BCHDecodeMPT1327for the 64-bit codeword layout MPT 1327 uses (48 info bits + 15 BCH check + 1 overall parity bit). Polynomialg(x) = x^15 + x^14 + x^13 + x^11 + x^4 + x^2 + 1(= 0x6815 without the implicit leading x^15) and 0x0001 initial fill — the parameters DSheirer/sdrtrunk uses inedac/CRCFleetsync.javafor Fleetsync and MPT 1327 (which share the codeword format). 48-entry syndrome table generated at package init fromx^i mod g(x)for the info bits. Single-bit error correction is best-effort: info-bit errors (positions 0..47) and parity-bit errors (position 63) recover the info field exactly; CRC-bit errors (positions 48..62) have known syndrome collisions with info bits 0..14 and are resolved by preferring info-bit correction (garbage at the info layer gets rejected by the protocol parser anyway). Tests cover round-trip, single-bit detection across all 64 positions, exact info-bit recovery for the unambiguous half of the position space, random round-trips, and a double-bit-error detection sanity check. Wiring this primitive into the MPT 1327 adapter via aSetBCHModeopt-in is the follow-up. - Gardner symbol-time recovery for complex IQ.
internal/dsp/sync/gardner.goadds a non-data-aided feedback timing-recovery loop sibling to the existing real-valuedMuellerMuller. Uses the standard Gardner 1986 detector —e[n] = Re{(s[n] − s[n−1])* · m[n]}over the symbol-time samples and the midpoint sample between them — which converges before the demod has acquired symbol polarity, so it works for π/4-DQPSK / QPSK / QAM IQ streams where Mueller-Muller would need an upstream rotation pass. Cross-call state preserves the timing estimate so chunked streams converge once rather than per-chunk. Tests cover aligned QPSK recovery, fractional-sample phase-offset pull-in, chunked-vs-contiguous symbol agreement, and reset semantics. Closes the README’s “Symbol-time clock recovery on complex IQ” primitive gap; threading it into the π/4-DQPSK receivers (P25 Phase 2, TETRA) is the follow-up. - P25 Phase 2 4-state ½-rate trellis FEC opt-in over the MAC
PDU. Second heavy-FEC PR.
phase2.SetTrellisMode(TrellisOn)switches theControlChannel.Processadapter from “read 72 raw MAC PDU dibits off the wire” to “collect 146 channel dibits + run them through the TIA-102 Annex A 4-state ½-rate trellis Viterbi decoder”. The trellis tables (16-entry constellation table from Annex A Table A.1) are extracted into a new shared primitiveinternal/radio/framing/p25_trellis.go(EncodeP25Trellis/DecodeP25Trellis) so both Phase 1 (TSBKs, 48 → 98 dibits) and Phase 2 (MAC PDUs, 72 → 146 dibits) can drive the same code; Phase 1’s existing local copy stays in place for backward compatibility. Tests cover the framing primitive (round-trip + single-dibit-error correction + length-check) plus end-to-end Phase 2 paths (KindCCLocked from a trellis-encodedOpMACPTTPDU;KindGrantfrom a trellis-encodedOpGroupVoiceChannelGrantPDU). The spec’s Reed-Solomon outer layer + per-burst block interleaver (which wrap around the trellis-coded MAC bits) are documented follow-ups; on-air decode of full P25 P2 traffic needs both layers and accurate symbol-time recovery (Gardner) to land. - NXDN K=5 ½-rate Viterbi FEC opt-in for the CAC region of the
Info field. First heavy-FEC PR.
nxdn.SetViterbiMode( ViterbiOn)switches theControlChannel.Processadapter from “read 44 raw CAC dibits off the wire” to “collect 92 encoded CAC dibits + run them through the K=5 ½-rate Viterbi primitive ininternal/radio/framing/viterbi_k5.goto recover 88 CAC info bits + 4 tail bits”. The convolutional primitive (constraint length 5, generator pair g1 = 0x19 / g2 = 0x17 octal 31/27) is the same one MMDVMHost / DSDcc / op25 use across NXDN SACCH and other K=5 open-spec systems; this PR wires it into the CAC slot. Tests round-trip CAC bytes →EncodeK5→ 184 channel bits → 92 dibits → Process → ParseCAC → cc.locked. The per-protocol interleave- puncture inner layer NXDN applies inside the Info field isn’t reversed yet (the public references don’t fully document it); ViterbiOn is the bare-bones convolutional decode, ViterbiOff (default) preserves the legacy raw-wire behaviour for test fixtures and clean synthesized streams.
- Cross-protocol strict-validation FEC bundle: LTR + dPMR +
TETRA + P25 Phase 2
SetStrictValidation(bool). Extends the soft-FEC noise filter from the previous PR across the remaining four protocols, completing the family of seven. Same pattern as the EDACS / Motorola / MPT 1327 bundle: each Ingest path now drops frames whose opcode / type / range fields fall outside the documented set before the state machine acts on them. dPMR rejects CSBKs whose 5-bit MessageType is unallocated (per ETSI TS 102 658 §6.5.2); TETRA rejects PDUs whose (Discriminator, Type) pair isn’t in the documented CMCE / MLE set (also drops MM and SDS sub- protocols, which the state machine doesn’t surface for trunking); P25 Phase 2 rejects MAC PDUs whose 8-bit Opcode is outside the TIA-102.AABF / BBAB table; LTR rejects Status words whose Channel or Home field falls outside the documented 1..20 range. Each protocol also gains anIsKnown()/IsWellFormed()method on its enum / status type for callers that want to apply the same allow-list themselves. Strict validation is now available on every protocol with an enumerable opcode space. - Cross-protocol strict-validation FEC bundle: EDACS +
Motorola + MPT 1327
SetStrictValidation(bool). Each adapter gains a soft-FEC noise-reduction mode that rejects parsed control-channel frames whose opcode / kind / command field falls outside the documented set. Same pattern across all three protocols: when on, the Ingest path drops frames with unrecognisedCommand/Opcode/Codeword.Kindbefore the state machine acts on them. Each protocol also gains anIsKnown()method on its enum type for callers that want to apply the same allow-list themselves. Doesn’t correct bit errors — that’s what BCH / RS / Viterbi do per protocol — but it cheaply eliminates the largest source of false-positiveKindCCLocked/KindGrantevents from misaligned codewords in environments without per-protocol FEC. - Motorola BCH(64,16,11) FEC.
framing/bch.gogainsBCHEncode64_16/BCHDecode64_16— the existing BCH(63,16,11) primitive used by P25 Phase 1 NID, extended with an overall- even-parity bit. The Motorola adapter gainsSetBCHMode(BCHOff | BCHOn); when on, the adapter reads two 64-bit codewords (128 channel bits) after each sync, decodes each via the framing primitive, and concatenates the recovered 16-bit halves into the 32-bit OSW. Uncorrectable codewords (> 11 errors) drop the frame silently. Tests cover the framing primitive (round-trip, single-bit corrections, parity-flip detection, > 11-bit rejection) plus an end-to-end Motorola Process call decoding a BCH-encodedOpGroupVoiceChannelGrantOSW. - FEC bundle: framing
ManchesterEncode/ManchesterDecode/ManchesterDecodeMajorityhelpers + LTR Manchester opt-in + NXDN CAC CRC strict-mode. First FEC implementations PR.framing/manchester.goadds a generic bi-phase encoder / strict decoder / soft (majority-decode) decoder usable by any protocol that ships Manchester-encoded bits on the wire. LTR gains aSetManchesterMode(ManchesterStrict | ManchesterSoft | ManchesterOff)config so deployments that use bi-phase encoding decode correctly; the default stays NRZ. NXDN’s CAC CRC-CCITT-16 (already verified insideParseCAC) is now enforced by the Process adapter — frames whose CRC fails get dropped silently instead of dragging the state machine through an Ingest call. Future EDACS / Motorola adapters can adopt the Manchester helpers in the same opt-in shape. - TETRA TMO
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC sync layer for TETRA — the last per-protocol adapter from the connector roadmap. The receiver’sDibitSinkforwards π/4-DQPSK dibits intotetra.ControlChannel.Process, which buffers across calls + detects the 38-dibit normal training-sequence sync + slices a 48-dibit PDU (1 header byte + 11 payload bytes = 96 bits) + parses it viaParsePDU+ dispatches through the existingIngest.trunking.ProtocolgainsProtocolTETRA(config string"tetra"). RCPC / RM FEC + interleaving across the full TDMA slot are documented follow-ups; until they land the adapter works on test fixtures but typically fails to lock on captured TETRA traffic. With this PR every trunked control modulation listed in the Features table has an end-to-end IQ → CC chain shipping. - P25 Phase 2
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC sync layer for P25 Phase 2: the receiver’sDibitSinkforwards H-DQPSK dibits intophase2.ControlChannel.Process, which buffers across calls + detects the 20-dibit outbound sync + slices a 72-dibit MAC PDU (1 opcode + 17 payload bytes = 144 bits) + parses it viaParseMACPDU+ dispatches through the existingIngest.trunking.ProtocolgainsProtocolP25Phase2(config string"p25-phase2"). Trellis FEC + slot-type extraction across the full 180-dibit subframe are documented follow-ups; until they land the adapter works on test fixtures but typically fails to lock on captured Phase 2 traffic. - DMR Tier III
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC chain for DMR — the most layered protocol in the family. The receiver’sDibitSinkforwards C4FM dibits into the adapter, which buffers across calls + runsdmr.SyncDetectoragainst all 9 ETSI sync words in parallel + slices the 132-dibit burst around each match (49-dibit first half + 5-dibit slot type before + 24-dibit sync + 5-dibit slot type after + 49-dibit second half) + parses the slot-type Hamming(20,8) codeword + hands the(Burst, SlotType)pair to the existingIngestBurst. From there the dmr/tier3 package’s BPTC(196,96) + CSBK CRC chain runs end-to-end — no FEC is bypassed for DMR. The adapter retains a 163-dibit cross-call buffer so bursts that straddle chunk boundaries decode correctly. - MPT 1327
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC alignment layer for MPT 1327: the receiver’sBitSinkforwards FFSK bits intompt1327.ControlChannel.Process, which slides a 38-bit window over the stream, commits to the first window that parses as a recognised Address codeword (Aloha / AhoyChan / GoToChan / Ack / Disconnect / Data / Emergency), follows the alignment forward, and auto-unlocks + re-searches after 8 consecutive frames whose codeword fails the recognised-codeword check.trunking.ProtocolgainsProtocolMPT1327(config string"mpt1327"). The 64-bit on-air BCH(63,38) FEC + de- interleaving are documented follow-ups; until they land the adapter works on noise-free test fixtures but typically fails to lock on captured MPT 1327 traffic. - LTR
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC alignment layer for LTR: the receiver’sBitSinkforwards sub-audible bits intoltr.ControlChannel.Process, which buffers across calls, slides a 41-bit window over the stream, commits to the first position whose Sync bit is set, and follows the alignment forward — unlocking + re-searching if a subsequent frame’s Sync bit drops to 0. Each successfully-aligned Status word is dispatched into the existingIngestpath.trunking.ProtocolgainsProtocolLTR(config string"ltr"). FCS verification over the 12-bit trailer + Manchester decoding of the on-air bit stream are documented follow-ups; until they land the adapter is honest about its noise floor (spurious alignments drop through the state machine’s Area / activeGroup dedup, correctly-aligned frames drive cc.locked + grants). - Motorola Type II
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC sync layer for Motorola: the receiver’sBitSinkforwards bits intomotorola.ControlChannel.Process, which buffers across calls- detects the 24-bit outbound sync + slices a 32-bit OSW out
of the wire + parses it via
OSWFromBits+ dispatches via the existingIngest.trunking.ProtocolgainsProtocolMotorola(config string"motorola"). The BCH(64,16,11) FEC + de- interleaving over the OSW are follow-ups; until they ship the adapter sync-locks but typically fails OSW parsing on noisy on-air signals.
- detects the 24-bit outbound sync + slices a 32-bit OSW out
of the wire + parses it via
- EDACS / GE-Marc
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC loop for EDACS: the receiver’sBitSinkforwards bits intoedacs.ControlChannel.Process, which buffers across calls + detects the 24-bit outbound sync + slices the 40-bit CCW + parses it viaCCWFromBits+ dispatches via the existingIngest.trunking.ProtocolgainsProtocolEDACS(config string"edacs"). On-air recovery margins improve once the per-CCW BCH(40, 28, 2) FEC layer (later wired asedacs_bch_mode: on) is enabled — Standard EDACS uses BCH as its only CCW-level FEC per thelwvmobile/edacs-fmreference. Earlier README revisions called out an outer “interleaved Reed-Solomon-derived FEC” as a follow-up; that was a documentation error, no such layer exists in Standard EDACS. - NXDN
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC sync layer for NXDN: the receiver’sDibitSinkforwards intonxdn.ControlChannel.Process, which buffers across calls + detects the 8-dibit outbound FSW + parses the LICH from the next 16 wire bits (doubled-bit majority decode viaDecodeLICHWire→ParseLICH) + pulls the first 44 dibits of the 144-dibit Info field as raw CAC bits →ParseCAC→IngestFrame. The CAC FEC layer (K=5 ½-rate Viterbi + interleaver + puncture across the full 288-wire-bit Info field) is the next NXDN follow-up; until it ships the adapter sync-locks but typically fails the CAC CRC on real on-air signals. Inbound (MS → BS) FSW matches are silently ignored since they don’t carry the CC announcement payloads the state machine locks on. - dPMR Mode 3
ControlChannel.Process(stream, baseIdx)adapter + ccdecoder factory. Closes the IQ → CC loop for dPMR: the receiver’sDibitSinkforwards intodpmr.ControlChannel.Process, which buffers across calls + detects the 24-dibit FS3 sync + slices the 40-dibit / 80-bit CSBK + parses it viaCSBKFromBits- dispatches via the existing
Ingest.trunking.ProtocolgainsProtocolDPMR(config string"dpmr") so the ccdecoder factory map can resolve it. First of the per-protocol adapter follow-ups from the connector PR.
- dispatches via the existing
- Daemon wiring for the IQ → CC decoder connector
(
cmd/gophertrunk/daemon.go). When the daemon’s pool has a control-role SDR + at least one trunked system configured, it constructs accdecoder.Decodernext to the existingcchunt.Supervisorand spawns it as a daemon goroutine. The connector owns the control SDR’sStreamIQloop, swaps the active per-protocol pipeline on everyKindHuntProgressretune, and pumps IQ chunks through the active pipeline whose CC state machine publishescc.locked/grantevents back on the bus — the trigger that lights up every downstream surface (engine, recorder, call log, API, TUI).make integrationnow boots the full chain with a mock SDR and asserts the connector is constructed + runs without crashing. - IQ → control-channel decoder connector (
internal/scanner/ccdecoder) — subscribes toevents.KindHuntProgress, owns oneStreamIQ(ctx)loop on the control SDR, swaps the active per-protocol pipeline (IQ → symbol-domain decoder → CC state machine) on every supervisor retune, and pumps IQ chunks through the active pipeline whose CC state machine publishescc.locked/grantevents back on the bus. Closes the load-bearing gap from “Status & known gaps”. P25 Phase 1 and YSF pipelines wire end-to-end today; other protocols register their factories once the per-protocolControlChannel.Process(stream, baseIdx)adapters ship. - TETRA TMO IQ → π/4-DQPSK dibit receiver (
internal/radio/tetra/receiver) composing thedemod.PiOver4DQPSKhelper (RRC matched filter at α = 0.35, π/4-rotated differential decode) with naive symbol- time decimation at 18000 sym/s into one entry point that fans dibits out via the newtetra.DibitSinkcallback. Last per-protocol receiver in the family — every trunked control modulation listed in the Features table now has an IQ → symbol / bit chain shipping in tree. Full symbol-time clock recovery (Gardner on complex IQ or eye-tracking on |y|²) is a follow-up; the connector that lands next wraps a timing-recovery loop around the π/4-DQPSK family when a real-air capture is available. TheControlChannel.Process(dibits, baseIdx)adapter that does 38-dibit training-sequence sync detect + burst slice + L3 PDU dispatch is the next layer up. - P25 Phase 2 IQ → H-DQPSK dibit receiver (
internal/radio/p25/phase2/receiver) composing thedemod.PiOver4DQPSKhelper (RRC matched filter + π/8-rotated differential decode) with naive symbol-time decimation at 6000 sym/s into one entry point that fans dibits out via the newphase2.DibitSinkcallback. Ninth per-protocol receiver — the first π/4-DQPSK-family one, leaning on the helper shipped earlier in the roadmap. Full symbol-time clock recovery (Gardner on complex IQ or eye-tracking on |y|²) is a follow-up; the connector will wrap a timing-recovery loop around this when a real-air capture is available. TheControlChannel.Process(dibits, baseIdx)adapter that does 20-dibit sync detect + MAC PDU slice + opcode dispatch is the next layer up. - Motorola Type II IQ → MSK bit receiver (
internal/radio/motorola/receiver) composing FM demod + Gaussian matched filter (BT = 0.5, the closest fit for an MSK matched filter) + Mueller-Müller clock recovery at 3600 baud + 2-level slicer into one entry point that fans bits out via the newmotorola.BitSinkcallback. Eighth per-protocol receiver in the family — reuses thedemod.GFSKhelper since MSK (mod-index 0.5 CPFSK) decodes cleanly through the same FM-discriminator + matched-filter chain. TheControlChannel.Process(bits, baseIdx)adapter that does 24-bit sync detect + 84-bit OSW slice + BCH(64,16) decode +ParseOSWIngestis the next layer up.
- LTR IQ → sub-audible bit receiver (
internal/radio/ltr/receiver) composing FM demod + a narrow sub-audible LPF (Kaiser-windowed FIR, ~300 Hz cutoff) + Mueller-Müller clock recovery at 300 baud- 2-level slicer into one entry point that fans bits out via the
new
ltr.BitSinkcallback. Seventh per-protocol receiver in the family. Manchester decoding + 41-bit Status framing live in the follow-upControlChannel.Process(bits, baseIdx)adapter.
- 2-level slicer into one entry point that fans bits out via the
new
- MPT 1327 IQ → FFSK bit receiver (
internal/radio/mpt1327/receiver) composing FM demod + FFSK tone discriminator (CCIR FFSK: mark = 1200 Hz / space = 1800 Hz) + Mueller-Müller clock recovery at 1200 baud into one entry point that fans bits out via the newmpt1327.BitSinkcallback. Sixth per-protocol receiver in the family and the first audio-band-FSK one — leans on thedemod.FFSKhelper shipped earlier in the roadmap. TheControlChannel.Process(bits, baseIdx)adapter that does cross-call bit buffering + 64-bit codeword slice + BCH(63,38) parity verification +ParseCodeword+Ingestis the next layer up. - EDACS / GE-Marc 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 into one entry point that fans bits out via the newedacs.BitSinkcallback. First non-C4FM per-protocol receiver in the family — leans on thedemod.GFSKhelper shipped earlier in the roadmap. TheControlChannel.Process(bits, baseIdx)adapter that does 24-bit sync detect + 40-bit CCW slice +CCWFromBits+Ingestis the next layer up. - dPMR Mode 3 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 (half the P25 P1 / DMR / NXDN / YSF rate, matching dPMR’s 6.25 kHz channel spacing) into one entry point that fans dibits out via the newdpmr.DibitSinkcallback. TheControlChannel.Process(dibits, baseIdx)adapter that does FS3 sync detect + 80-bit CSBK slice +CSBKFromBits+Ingestis the next layer up. - NXDN IQ → C4FM dibit receiver (
internal/radio/nxdn/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer into one entry point that fans dibits out via the newnxdn.DibitSinkcallback. Targets the 9600-baud 4-FSK variant (the same C4FM modulation P25 P1 / DMR / YSF use); the 4800-baud BFSK variant — 2-level slicer rather than 4-level — is a follow-up. TheControlChannel.Process(dibits, baseIdx)adapter that does 8-dibit FSW detect + 192-dibit frame slice + LICH / SACCH decode +IngestFrameis the next layer up. - DMR IQ → C4FM dibit receiver (
internal/radio/dmr/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer into one entry point that fans dibits out via the newdmr.DibitSinkcallback. Same shape as the YSF / P25 P1 receivers (4800-baud C4FM is shared across the 4FSK family); the cross-call buffering + sync-detect + 132-dibit burst assembly +Process(dibits, baseIdx)adapter ontier3.ControlChannelis the next layer up. - YSF IQ → C4FM dibit receiver (
internal/radio/ysf/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer into one entry point that fans dibits out via the newysf.DibitSinkcallback — wire it intoysf.ControlChannel.Processto drive the per-frequency state machine on real IQ. Same shape as the P25 Phase 1 receiver (4800-baud C4FM is the shared modulation); SymbolToDibit follows the P25 / DSDcc convention pending real-air FSW-pattern validation. - Vocoder calibration harness (
internal/voice/calibrate/) —Compare(raw, refWav, vocoderName)returns RMS-ratio (dB), normalised cross-correlation, and best alignment lag against an external decoder’s reference WAV. Unit tests cover the RMS + cross-correlation primitives + a WAV round-trip via the sharedvoice.WavWriter; integration tests for IMBE / AMBE+2 skip cleanly until the testdata fixtures land. The harness’s failure output names the AGC constant ininternal/voice/mbe/agc.go:DefaultAGCConfigto adjust. - Police-scanner subsystem (
internal/scanner/{cchunt,conventional}) — multi-system CC Hunter supervisor with hold/resume/force-retune, conventional FM scan list with IQ-power squelch, talkgroup scan list with global ScanMode, 10th TUI panel and REST cockpit at/api/v1/scanner. - TUI Devices panel +
GET /api/v1/devices+sdr.attached/sdr.detachedevent publishing in the SDR pool. - TUI drill-in modals on Systems and Talkgroups (Enter).
- P25 Phase 1 IQ → C4FM dibit receiver (
internal/radio/p25/phase1/receiver) composing FM demod + RRC matched filter + Mueller-Müller clock recovery + 4-level slicer into one entry point that fans out to both the LDU assembler (voice path) and an optional raw-dibit sink (phase1.DibitSink— control-channel path). - YSF FICH Trellis decoder + grant emission on Header FICH for
Group calls (
internal/radio/ysf/fich_trellis.go+ extendedcontrol.go). - Pure-Go RTL-SDR driver (
internal/sdr/rtlsdr/{usb,rtl2832u,tuners,purego}/) replaced thelibrtlsdr+libusbC dependency. Pure-Go USB transports for Linux (USBDEVFS), Windows (WinUSB), and macOS (IOKit viapurego); RTL2832U register/I2C layer; R820T/R820T2/R828D + E4000 + FC0012 + FC0013 + FC2580 tuner drivers. Default builds runCGO_ENABLED=0end-to-end. - Pure-Go IMBE vocoder (
internal/voice/imbe/+ sharedinternal/voice/mbe/) and pure-Go AMBE+2 vocoder (internal/voice/ambe2/) — both produce intelligible audio end-to-end with shared AGC, §6.2 spectral enhancement, frame-repeat on bad-frame indicator, phase-aware fade-in. - Higher-fidelity FM voice chain: opt-in 75/50µs de-emphasis,
Kaiser-windowed audio LPF, audio AGC, polyphase L/M audio
resampler (
composer.{DeEmphasis,AudioLPF,AudioAGC,AudioResampler}Config).
Tech stack
- Language: Go 1.25+ (toolchain pinned to 1.25.10 in
go.mod) - Hardware: Pure-Go RTL-SDR driver — USBDEVFS / WinUSB / macOS IOKit transport, RTL2832U register layer, and per-chip tuner drivers (R820T/R820T2/R828D/E4000/FC0012/FC0013/FC2580).
CGO_ENABLED=0; nolibrtlsdr/libusbbuild dependency. - DSP:
gonum/dsp/fourierfor FFT, custom polyphase channelizer, filters, and demodulators (FM / C4FM / GFSK / FFSK / π/4-DQPSK / H-DQPSK) - Vocoders: Pure-Go IMBE + AMBE+2 (clean-room re-implementations
of mbelib codebook tables); optional DVSI USB-3000 hardware
backend behind
-tags dvsi - Audio: Pure-Go ALSA backend (direct-ioctl + libasound2 via
purego), macOS CoreAudio + Windows WASAPI via
ebitengine/oto - Storage:
modernc.org/sqlite(pure Go) - API: gRPC + Protobuf, HTTP/SSE, WebSocket; optional TLS; bearer-token auth on mutations
- TUI:
charmbracelet/bubbletea10-panel cockpit - Logging:
log/slog(stdlib) - Metrics:
prometheus/client_golang - CI: GitHub Actions running
go vet+go test -race+make integration+make test-dvsi+govulncheck+ license audit across Linux / macOS / Windows runners
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 |
1–9, 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
docs/architecture.md— layered overview, concurrency model, driver registry, build tagsdocs/tui.md— TUI keybindings, panel reference, troubleshootingdocs/hardware.md— udev rules, DVB blacklist, IQ capture for replaydocs/vocoders.md— IMBE / AMBE+2 licensing realities, the plugin model, the DVSI backend layout, and the knox / call-alert extension hookdocs/voice-calibration.md— operator recipe for tuning the IMBE / AMBE+2 decoders against a DSD-FME / OP25 reference recording viacmd/voice-calibratedocs/hardening.md— API authentication, TLS setup, health endpoint diagnostics, HTTP / gRPC timeouts + keep-alive reference, graceful shutdown, Prometheus catalogue, Docker / compose USB pass-through, smoke-test checklistdocs/opt-in-features.md— operator reference for every default the daemon carries: protocol FEC defaults, receiver clock recovery, daemon-level features (mix of on / off / auto-detect), and the permanent build-time gates (DVSI patent tag, integration tests)docs/gophertrunk.service— example systemd unit (DynamicUser, ProtectSystem, USB device-allow) for Linux operators standing the daemon up on a serverdocs/specs/— reference air-interface PDFs the on-air FEC implementations derive from (NXDN-TS-1-A, ETSI EN 300 392-2 TETRA, plus a negative-reference M/A-COM LBI for EDACS that documents not what to look for)
Project metadata
CHANGELOG.md— user-visible changes per release, Keep-a-Changelog formatCONTRIBUTING.md— dev setup, PR scoping rules, house-style conventions, release-cutting recipeSECURITY.md— vulnerability disclosure process via GitHub’s private security advisories; in-scope vs. out-of-scope; response-time SLAsTHIRD_PARTY_LICENSES.md— direct Go-module deps + the mbelib ISC attribution for the AMBE+2 / IMBE codebook tables
License
See LICENSE.