# Changelog All notable user-visible changes land here, newest first. Format adapted from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for tagged releases. ## [Unreleased] ### Added - **TETRA demodulator equalizer recovers garbled voice recordings.** On concurrent-load captures a residual garble survived the soft-decision TCH/S decode: a linear channel / ISI defect (multipath, band-edge group delay) was smearing the π/4-DQPSK constellation — the demod-side gap between GopherTrunk and radios that equalize. A blind Constant-Modulus-Algorithm equalizer now sits between symbol-timing recovery and the differential decoder on the TETRA voice path, inverting that channel. Across the reporter's six captures it roughly doubles CRC-valid TCH/S burst yield (soft-decision 410→778, ~1.9×; one call went 4→207 bursts, another 42→134) with no loss on already-clean captures. On automatically (voice path); no configuration change. Refs #764, #771, #1001. - **Cross-site duplicate-recording suppression (`recordings.dedup`).** When monitoring several networked / simulcast sites where the same talkgroup carries the same traffic, a call heard on more than one system is now saved once instead of once per site. Enable with `recordings.dedup.enabled: true` (`window_seconds` defaults to 60): a call whose `(talkgroup, source)` was already recorded from a *different* system within the window is skipped. Keying on the calling-radio RID (globally unique in a network) means two genuinely different calls that share a talkgroup number across systems still both record when their sources are known; a re-key on the same system is never suppressed; and live monitoring is unaffected (recording-only). Off by default. - **`GET /api/v1/grants` — a pollable log of recent control-channel grants.** GopherTrunk decodes a source RID off most `GRP_VCH_GRANT` TSBKs, and already streams every grant live over the `grant` SSE event; this adds the pollable snapshot form so a telemetry consumer can read the source RID (plus talkgroup, frequency, channel, site and encryption) straight off the grant — the way SDRtrunk's control-channel grant log exposes it — without holding an SSE connection. The response reuses the same stable `GrantDTO` schema the SSE stream emits, with a per-row `at` timestamp; `?limit=` and `?system=` narrow the result. Backed by a bounded in-memory ring (newest-first, always on), so a busy system never grows the log without bound (issue #915, reporter fix #2). - **TETRA control-channel PDUs that span multiple MAC blocks are reassembled (MAC-FRAG / MAC-END).** A TM-SDU too large for one MAC block arrives as a start-fragment MAC-RESOURCE followed by MAC-FRAG and a MAC-END (ETSI EN 300 392-2 §21.4.3.3/.4); those continuation PDUs were previously dropped, losing the L3 CMCE PDU carried across them (a large D-SETUP's call-identifier→group mapping, calling-party SSI, and emergency flag). The channel allocation is complete on the start fragment, so the voice grant still publishes immediately; the reassembled MAC-END now recovers the full call-control PDU and enriches the call with its source and emergency state. - **TETRA network-configuration report now shows the control channel's uplink.** The cell's uplink carrier, derived from the SYSINFO duplex spacing and the frequency offset (§21.4.4.1), was computed but dropped before the report; the primary control channel now renders its `UPLINK:` frequency alongside the downlink. - **Soft-decision decode on the TETRA grant (SCH/F + SCH/HD) path.** The signalling-channel decode on the grant-bearing blocks now uses the per-symbol soft (log-likelihood) information when available, falling back to the hard-decision Viterbi path, recovering grants on marginal control channels that a hard decision drops. - **P25 Phase 2 blind CMA equalizer on the traffic-channel receiver (`p25_phase2_equalizer: on`).** A new opt-in adds a blind constant-modulus (CMA) adaptive equalizer on the Phase 2 symbol stream, after carrier recovery and before the differential decode. It removes residual inter-symbol interference a real channel leaves on the symbols — RRC pulse-shape mismatch, a fractional Gardner timing error, mild multipath — which widens the differential-phase decision and costs the outer RS(24,16,9) symbol errors on an otherwise-decodable burst. CMA (not decision-directed) is used because the H-DQPSK absolute constellation spins π/8 per symbol and has no fixed phase grid, so a phase slicer has nothing to lock to; CMA is rotation-invariant and drives the modulus toward unity blindly, leaving the phase to the differential decode. Through the production receiver, a multipath channel that dropped a Phase 2 MAC-payload recovery to a fraction of bursts is restored to near-full recovery; a clean signal decodes byte-identically (the centre-spike init is transparent). Off by default — an AWGN-limited channel gains nothing from equalization, so it is opt-in; pairs with `p25_phase2_soft_decision` and `p25_phase2_rs_mode: correct`. (issue #915) - **P25 Phase 2 outer RS can now error-*correct* weak MAC PDUs (`p25_phase2_rs_mode: correct`).** The `p25_phase2_rs_mode` knob gains a `correct` (alias `fix` / `ecc`) setting that runs the RS(24, 16, 9) outer code as a bounded-distance error corrector — repairing up to t=4 GF(2⁶) symbol errors (Berlekamp-Massey + Chien + Forney) before the MAC PDU is parsed — instead of the detection-only `on` mode that drops any PDU with a residual symbol error. This is the weak-frame recovery path for the completed-call source-RID gap: a traffic-channel `GROUP_VOICE_CHANNEL_USER` PDU that framed and descrambled at the right phase but carries a handful of symbol errors from marginal-SNR demod now still yields its source RID, where before it was rejected and the call landed with no source. `correct` strictly supersets `on` (a clean codeword decodes identically); because t=4 correction admits a small fraction of random windows, a PDU that actually needed correction is additionally gated on a recognised MAC opcode so a wrong descramble phase cannot be miscorrected into a bogus source RID. Off by default; pairs with `p25_phase2_soft_decision: on`. (issue #915) - **`call.end` real-time event now carries `duration_ms`.** The SSE/WebSocket event stream's call-completion event (`event: call.end` on `/api/v1/events` and the WS stream) now includes the call length in milliseconds alongside `started_at`/`ended_at`, matching the completed-call webhook's `duration_ms`, so an SSE/WS-only consumer (a Prometheus exporter, a Grafana feed) reads a call's duration off the completion event without pairing it back to `call.start` and subtracting timestamps itself. Also confirmed and pinned by a regression test that the P25 control-channel `nac` — site identity alongside `rfss_id`/`site_id` — is present on every P25 grant / affiliation / registration event (threaded from the decoded NID; omitted only for non-P25 protocols where NAC does not apply). (issue #268) - **Motorola P25 talker-alias ciphertext is logged as cryptanalysis ground truth.** When a Motorola FACCH-S talker alias reassembles on a Phase 2 traffic channel, GopherTrunk now emits a `p25p2 alias ciphertext` log line carrying the source RID, talkgroup, and the reassembled encoded-alias bytes (`encoded_hex`) — the `rid,talkgroup,encoded_hex,alias` record the alias cipher analysis consumes. The proprietary alias cipher is still gated (unsolved, so the decoded name stays blank), but this lets an operator harvest the chosen-plaintext / known-RID corpus needed to finish it (`research/p25-talker-alias-chosen-plaintext.md`) using GopherTrunk alone instead of SDRTrunk (#773). - **P25 Phase 2 soft-decision demod (`p25_phase2_soft_decision`).** A new per-system opt-in knob (on/off, default off) that builds the Phase 2 traffic-channel receiver with soft-decision decoding: the demodulator's soft symbol differentials feed a per-bit soft Viterbi on the MAC trellis, recovering the ~1.5–2 dB of coding gain the hard slicer discards. On weak signals this can surface the clear-MAC source RID the hard path drops; on strong signals it is neutral, and with the knob off the decode is byte-for-byte unchanged. Applies to the voice composer and signalling follower (including Phase 1 control channels that grant Phase 2 traffic). Issue #915. - **`baseband.auto_record.tap: ddc`** — event-triggered auto-captures can now record the control decoder's narrowband post-DDC stream (the pipeline rate, 144 kHz for TETRA) instead of the full-rate wideband SDR IQ. Files are orders of magnitude smaller (a few MB vs ~95 MB) and directly replayable; for a same-carrier TETRA site the DDC tap holds all four voice timeslots of the control carrier. `tap: wideband` (default) is unchanged. Triggered captures also create their target directory if missing. - **TETRA voice now decodes to audible audio, including up to 4 concurrent calls on one carrier.** The TETRA voice path recovers each traffic burst, channel-decodes TCH/S, and renders it with the clean-room ACELP vocoder (`tetra-acelp`), now bit-exact to the ETSI EN 300 395-2 reference. Each burst is tagged with its AACH downlink usage marker (the per-slot call identifier), and the daemon registers up to four `cc:same-carrier:N` taps, so up to four simultaneous calls on one control carrier — demultiplexed by matching each call's grant usage marker — record into their own files instead of only one binding and the rest being dropped with "no voice device available for grant". The same-carrier voice device serial changes from `cc:same-carrier` to `cc:same-carrier:1..4`. - **Event-driven raw-IQ auto-recording (`baseband.auto_record`).** The daemon can now capture a short slice of the control SDR's raw IQ whenever a classified event fires — `on_concurrent_calls: N` (N+ calls active at once), `on_no_voice_device` (a grant arrived but every voice tuner was busy), `on_encrypted`, or `on_emergency` — plus a manual `POST /api/v1/siglab/autorecord/trigger`. Each capture lands in the configured `dir` with a self-describing name (`____hz.`) and a `.metadata.json` sidecar, so it drops straight into `gophertrunk replay` / siglab. A cooldown and an in-flight cap keep a burst of grants from spawning a capture storm. This is the event-based debugging hook for capturing hard-to-decode moments (concurrent grants, unknown packets) as they happen. - **`baseband.auto_record` can capture the narrowband DDC output (`tap: ddc`).** The default `tap: wideband` records the control SDR's full-rate raw IQ (~50 MB per 30 s at 2.5 MS/s); `tap: ddc` instead records the control decoder's channelised post-DDC stream at the pipeline rate (144 kHz for TETRA, ~48 kHz for the C4FM family) — orders of magnitude smaller and directly replayable with `replay -format wav` / siglab. For a same-carrier TETRA site the DDC tap holds all four voice timeslots of the control carrier, so a triggered capture of a hard-to-decode concurrent-call moment stays small enough to share. Triggered captures also now create their target directory if it does not exist. - **siglab capture start time in the capture list.** Each capture row now shows its recording start time, so otherwise-identical grabs of the same carrier (e.g. three `capture-AIRSPY SN:…` captures) can be told apart at a glance. - **Startup warning when paging is configured without storage.** A `paging.pocsag` / `paging.flex` / `paging.wideband` subsystem with no `storage.path` set decodes fine and consumes live IQ, but decoded pages are never persisted and `GET /api/v1/pager/messages` returns `503`, so the Pagers panel shows a bare `pager/messages 503` with no hint that storage is the missing piece. The daemon now surfaces this at load time as a startup warning (alongside the launcher / TUI dashboard warnings) instead of leaving the misconfiguration silent. (issue #565) - **Per-site P25 Phase 1 demod mode on wideband multi-site taps.** A single P25 system can now mix modulation across its sites: set the system-level `p25_phase1_demod_mode` to the majority modulation and add a per-channel `p25_phase1_demod_mode:` override on the wideband dongle's `channels:` entries for the exceptions (blank inherits the system default). Recognised values match the system-level key (`c4fm`/`fm` vs `cqpsk`/`lsm`/`linear`). The override is keyed per control-channel frequency — the one place a site's identity is known before its CC locks — and drives both the control-channel decode and the voice grants that tap issues, so a granted call on an LSM simulcast site is decoded on the LSM path instead of timing out with no LDU (a C4FM demodulator can't recover a linearly-modulated simulcast signal — the symptom was `control_channel_decode_quality: poor` on urban simulcast sites regardless of hardware). This also closes a latent gap where the wideband path never stamped a demod mode onto its grants at all, so wideband P25 voice always ran the C4FM chain regardless of the system setting. (issue #935) - **LoRa Low Data Rate Optimization (LDRO).** The LoRa PHY now decodes the reduced-rate payload mode LoRa uses at high spreading factors, where the long symbol lets clock drift smear the dechirp peak across the two least-significant chip bins. LDRO carries the payload at SF-2 meaningful bits per symbol (the transmitted bin is always a multiple of four), tolerating that drift. It is auto-detected from the (SF, BW) pair per Semtech's Ts >= 16 ms rule — on for SF11/SF12 at 125 kHz and SF12 at 250 kHz — on both the demodulator and the built-in modulator, so no configuration is needed for standard networks; without it those (very common, long-range) frames could never be decoded. A per-sub-channel `low_data_rate_optimize: auto | on | off` override handles networks that deviate from the recommendation. (issue #586; the reduced-rate SF-2 *header* region and bit-exact Semtech interop remain gated on captured golden vectors.) - **P25 Phase 2 per-call MAC framing-health signal (`mac_rs_valid`).** The end-of-call census line now reports how many of the decoded traffic-channel MAC PDUs carried a valid outer RS(24,16,9) parity, and the per-opcode `p25p2 mac pdu` debug line carries an `rs_valid` flag. `mac_pdus>0` with `mac_rs_valid=0` is the built-in fingerprint of a mis-framed / mis-descrambled Phase 2 superframe (random bytes parsing as MAC PDUs rather than real signalling) — the objective before/after metric for a superframe-framing fix, and the reason a system's clear-MAC source RID never lands (issue #915). - **P25 Phase 2 control-channel MAC opcode census (`--log-level debug`).** A new diagnostic inventories every MAC opcode seen on the control channel — logging a one-shot `payload_hex` sample the first time each opcode appears plus a periodic `opcode:count` summary. Unlike the existing per-grant DEBUG line, it has no survivorship bias: it also surfaces opcodes GT does not currently parse as a grant, which is where a RID-bearing grant would hide on a system whose completed-call `source_rid` under-populates (issue #915). The raw opcode inventory + byte sample is what pins the remaining gap on a missing/mis-mapped grant opcode (decode-side) vs. a call-association gap. - **TETRA AACH downlink usage marker parsing (per-slot control/traffic identification).** GopherTrunk now decodes the ACCESS-ASSIGN PDU the AACH carries in the centre of every TETRA downlink slot into its downlink usage marker (`unallocated` / `assigned control` / `common control` / `traffic`, per ETSI EN 300 392-2 §21.4.7). This is the per-slot MCCH indicator a Single Carrier Base Station running dynamic MCCH sharing relies on, where any of the four TDMA slots can be the control channel at a given moment rather than a fixed slot 1 — the building block for issue #925. ### Fixed - **TETRA radio IDs still surfaced as talkgroups in the live Active Calls list.** The engine's RadioID→TGID retraction cleaned the Talkgroups catalogue but not the control-only "observed" call set, which is the other half of what the Active Calls UI renders: a notification / D-CONNECT bound to the calling party creates a provisional call keyed by the radio ID, and the notification-supersede only released the pool-bound call, leaving the observed entry to linger as a phantom "TG ". The engine now skips individual / known-radio grants when recording observed calls, and retracts any observed call already tracked under an SSI once that SSI is revealed to be a subscriber radio. - **TETRA metrics weren't counting.** `grants_total` was never defined or incremented (the metrics event handler had no `KindGrant` case), so TETRA grants went uncounted; TETRA voice calls were miscounted under the DMR-named `dmr_voice_calls_total` (its per-timeslot increment was gated only on a non-zero timeslot, which TETRA also carries); and the curated dashboards asked for a `cc_locked` metric the daemon never emits (it exports `control_channel_locked`). Added a protocol-labelled `grants_total`, gated `dmr_voice_calls_total` to the DMR protocol, and pointed the TUI/web curated panels at `control_channel_locked`. - **A single-call same-carrier TETRA burst whose AACH usage marker miscorrected was dropped instead of decoded.** The shared per-carrier voice demux routes by AACH downlink usage marker; on a marginal signal the RM(30,14) AACH occasionally miscorrects to a stray marker with no registered owner, so the burst was counted as an `ownerless_drop` and its speech lost. When exactly one call is active the demux now routes such a burst to that sole call through the class-2 CRC gate (the same single-call fallback already used for an undecoded AACH) — the CRC rejects a genuinely foreign burst ~255/256, and with one call there is no peer to cross-talk into. With ≥2 concurrent calls the burst is still dropped, so the cross-talk guarantees are unchanged. - **TETRA radio IDs leaked into the Talkgroups list.** A notification / D-CONNECT addressed to a call's calling party arrives as a bare grant before that SSI is known to be a radio, so it was published `Individual=false` and the engine auto-catalogued the radio ID as a phantom talkgroup (e.g. `100xxxx` source IDs showing up alongside real `102xxxx` talkgroups). The engine now learns which SSIs are subscriber radios — any grant's calling/transmitting party (`SourceID`) and any `Individual`-addressed destination — retracts a phantom talkgroup already catalogued for one (`TalkgroupDB.DeleteDiscovered`, which only removes auto-`Discovered` entries, never an operator-catalogued talkgroup), and refuses to re-discover a known radio. GSSIs and ISSIs never overlap on a TETRA network, so this is safe. - **TETRA voice recordings on a same-carrier site came out short and garbled (low `audio_pct`).** The TCH/S traffic decoder was hard-decision while the control channel already ran soft-decision, so on a marginal same-carrier signal ~70% of a call's own speech bursts failed the class-2 CRC and were dropped; the recorder concatenates only the surviving bursts, so the audio played back choppy/robotic (the `recording shorter than call span` diagnostic showed `audio_pct` ≈ 30%). The TCH/S channel decode now uses the receiver's per-symbol soft (log-likelihood) information — a soft-input rate-1/3 Viterbi mother decoder with soft depuncture and descramble, mirroring the existing soft SCH path — recovering the ~2 dB of coding gain the hard path threw away. Bursts with no soft info still fall back to the hard decoder, so the change is never worse than before. - **TETRA group calls could land on the wrong talkgroup with starved audio when the first grant was a notification.** On a same-carrier site a group call's first control-channel grant is often a notification / a D-CONNECT addressed to the calling party, which arrives before that party is known as a radio ID — so it is labelled with the individual's SSI (a phantom talkgroup), carries no source, and follows the notification's own downlink usage marker rather than the traffic channel's. The recording bound to that provisional identity, and when the authoritative group grant for the same physical channel arrived, the physical-channel source backfill folded it on as a mere source update — so the recording stayed on the wrong talkgroup and kept following the wrong usage marker, starving the voice demux (a few percent of the call decoded). The engine now supersedes a still-unfinalised, notification-bound TETRA call when the authoritative group grant (real GSSI, calling-party source, traffic usage marker) lands on its channel, so the recording follows the correct talkgroup and marker from the outset. TETRA-gated; P25 same-channel grants are unchanged. - **TETRA: a corrupt control-channel block whose CRC happened to pass could surface a ghost grant on a foreign frequency band.** A false-positive SCH CRC (or a slipped bit cursor) produced a bogus voice grant whose carrier resolved tens of MHz from the site — e.g. `carrier 6 → 400 MHz` on a 467 MHz cell — spawning a phantom call on a band the site does not operate. Grants whose resolved carrier sits more than 10 MHz from the control channel are now dropped as bit-alignment / CRC artefacts (a site's downlink carriers span at most a few MHz). - **`auto_record` with `tap: ddc` reported `drops=0` even when the DDC grab had gaps.** A triggered narrowband capture that fell behind the voice fan-out dropped IQ chunks (time gaps that break downstream decode), but the "captured" log line always showed `drops=0` — the drop count was only surfaced in a separate fan-out warning. The capture now reports the real subscriber drop count, matching the wideband tap, so a gappy grab is visible in its own result. - **TETRA subscriber/talkgroup identities (ISSI/GSSI) were corrupted on a live single-carrier site.** The MAC TM-SDU is an LLC PDU (§21.2); its basic-link header was not stripped before the L3 CMCE PDU was parsed, so the 3-bit MLE discriminator and every address field were misframed and the decoded radio / talkgroup IDs were wrong. GopherTrunk now parses the LLC sublayer (`ParseLLC`) before CMCE, recovering the correct ISSI/GSSI. - **TETRA grant frequencies ignored the SYSINFO frequency offset.** The broadcast frequency-offset field (0 / ±6.25 / +12.5 kHz, §21.4.4.1) was dropped, so a cell whose carrier actually sits at e.g. 469.88125 MHz resolved to 469.875 MHz. The offset (with the frequency band, duplex spacing, and reverse-operation flags) is now parsed and applied, so grant carriers resolve to their true absolute downlink/uplink frequencies. - **Ghost TETRA recordings from teardown PDUs.** A MAC-RESOURCE whose CMCE PDU is a call teardown (D-RELEASE, and now D-DISCONNECT, EN 300 392-2 Table 14.9) carries a channel-allocation element for the resource being *reclaimed*, not a new call; publishing a grant for it spawned a zero-byte phantom recording. Such grants are now suppressed, and D-DISCONNECT is modelled so it tears the call down instead of leaking a ghost. - **Individual (unit-to-unit) TETRA calls were surfaced as phantom talkgroups.** A grant addressed to a subscriber ISSI showed the radio ID as if it were a talkgroup. Calls are now classified individual-vs-group (any SSI seen as a CMCE calling/transmitting party is an individual radio), and an individual-addressed grant is flagged as such (`GrantDTO.individual`) instead of being listed as a talkgroup. - **TETRA active-call tracking thrashed on a busy carrier (marker collisions, starved audio).** Grants for the same call on one carrier could be keyed as separate calls, colliding on the voice tap and starving audio. Same-carrier TETRA grants that share a (system, frequency, timeslot) are now folded into one call, backfilling the source onto the existing recording. - **TETRA colour code locked on the first sync burst, so a single mis-decoded BSCH could poison a whole session.** The extended colour code (the scrambler seed for every BNCH/SCH/TCH block) was learned from the first BSCH and never re-evaluated. If that burst's bit errors slipped through the BSCH FEC as a valid-but-wrong codeword, the wrong scrambler locked in and every subsequent descramble failed silently — empty or truncated recordings until a restart. The colour is now adopted provisionally on the first BSCH (so a cold receiver still starts decoding immediately) but only locked once a second BSCH corroborates it; a mis-decoded first burst is corrected by the true colour that every later BSCH carries. An operator-configured colour is still authoritative. - **Concurrent same-carrier TETRA calls could still leak audio between each other** — a long conversation would pick up a second slot's speech mid-stream. The per-call demux ran one receiver per call and routed by the AACH usage marker, but could not evict a peer: when a call ended and the network reused its usage marker for a new call while the old one lingered in hangtime, both matched and the old recording absorbed the new call's audio. Concurrent same-carrier calls now share ONE per-carrier voice demux (its sync/AACH state stays warm across calls) with a single owner per usage marker and most-recent-grant-wins eviction, so a reused marker immediately displaces the lingering call. Routing by the AACH usage marker (not the physical TDMA slot) is what makes this reliable: on real captures a single call's bursts jitter across adjacent decoded slot numbers, so slot-keyed routing mis-delivers them — the usage marker is stable per call. Grants addressed without a usage marker bind to the first unclaimed marker instead of accept-all-mixing. - **A locked TETRA control channel flapped "CC hunt failed · candidates exhausted" every ~30–60 s while it was decoding calls fine.** Two coupled gaps: the control-channel hunter's success test needs a fresh `cc.locked` event within its dwell (default 3 s), but TETRA emits the lock only once (edge-triggered), so when cold acquisition (BSCH sync + colour code) outlasts the dwell the first hunt fails and every same-frequency re-hunt then exhausts the dwell against an already-locked, silent-on-the-wire pipeline; and there was no TETRA lock-loss watchdog (`MarkLost` had no callers), so the scanner could never leave the locked state on a genuine outage. Now the supervisor parks a system it already knows is locked instead of re-hunting it, and a new control-channel watchdog publishes `cc.lost` when a locked carrier decodes nothing for ~5 s (≈5 missed multiframes), so a genuinely dead carrier still re-hunts and recovers. (The ±6 kHz AFC acquisition range, #940, was already merged and is unrelated — a ~1.5 kHz carrier offset sits well inside it.) - **Concurrent same-carrier TETRA calls decoded as "DJ scratches" — most calls produced only brief garbled fragments.** The per-slot demux dropped a burst whose decoded TDMA timeslot did not match the call's *granted* timeslot, but on real air the grant timeslot does not identify the physical slot (distinct calls collide on one value; the synchronisation-burst slot anchor also jitters a call's bursts across adjacent slot numbers), so most calls had their own speech discarded as "off-slot" and recorded only the handful of frames decoded before the slot grid anchored. The voice chain now demultiplexes concurrent calls by the **AACH downlink usage marker** — the per-slot call identifier the AACH broadcasts in every downlink slot, matched against the usage marker carried in the call's grant — which cleanly separates simultaneous calls on one carrier. A grant addressed without a usage marker, or a burst whose AACH does not decode, falls back to CRC-gated single-call decoding so a call's speech is never dropped on a guess. Verified against a real 5-capture same-carrier IQ set: the two concurrent calls that the timeslot filter starved now recover their full audio. - **TETRA TCH/S class-2 CRC was computed wrong, so no on-air voice ever decoded.** The 8-bit CRC was a `G(X)=1+X³+X⁷` LFSR; the TETRA CRC (EN 300 395-2 §5.5.1) is a fixed parity-check matrix, so every received TCH/S burst failed the check and was dropped (recordings held only spurious frames). Reimplemented from the ETSI reference tap tables — real voice now decodes. - **TETRA voice calls could hang forever, monopolising the same-carrier tap.** Call liveness is now driven by CRC-valid decoded speech rather than every raw carrier burst, so a call ends on hangtime when its transmission stops and the tap is freed for the next grant. - **Config guidance no longer conflates LSM/simulcast with CQPSK.** The `p25_phase1_demod_mode` docs, config-builder help, `config.example.yaml`, and the per-site override example (issue #942) all implied that a *simulcast* site needs the `cqpsk`/`lsm` demod path — using Victoria's MMR / Melbourne CBD as the worked "→ cqpsk" example. That is backwards: simulcast is a transmitter-coordination technique, not a modulation, and most simulcast systems (MMR included, every site) transmit C4FM — forcing CQPSK there kills the decode. Linear Simulcast Modulation (LSM/CQPSK) is a *choice* some systems make, not implied by simulcast and not readable from emission-designator/licensing data. All guidance now says: leave it at C4FM, and switch a channel to `cqpsk` only when a strong, clean signal won't lock in C4FM. The per-channel override feature itself is unchanged and still correct for genuinely-CQPSK systems. A follow-up completes the same correction on the surfaces the first pass missed — the opt-in-features reference, the web config-builder's per-channel demod-override help, and the system-identification learn article. (issue #935) - **P25 Phase 2 superframes now lock under any dibit rotation.** Real-air Phase 2 is differentially decoded H-DQPSK, so a residual carrier offset near an odd multiple of ±1500 Hz (a quarter of the 6000-baud symbol rate) rotates every recovered dibit by a constant 0..3. The superframe decoder only correlated the outbound frame sync under the single canonical rotation, so a rotated stream never locked — no superframe, no traffic-channel MAC PDU, and hence no source RID or talker alias. It now searches all four rotations (the three non-canonical ones at a stricter tolerance so noise cannot false-lock) and de-rotates the sliced superframe to canonical before the ISCH + MAC FEC. Verified against a real Victorian MMR (WACN 0xBEE00) Phase 2 voice capture: 0 superframes before, ~430 after. (issue #915; the traffic-channel MAC descramble mapping that keeps `mac_rs_valid=0` even on a locked superframe is a separate, still-open blocker.) - **P25 Phase 2 MAC descramble moved to the coded-channel-bit domain.** The PN44 scrambler (TIA-102.BBAC-1 §7.2.5) wraps the burst "between demodulation and FEC", i.e. over the coded channel bits — but GopherTrunk XORed the recovered 144 *information* bits *after* the trellis decode. The trellis code does not commute with the XOR, so a genuinely scrambled burst could never satisfy the outer RS(24,16,9) check regardless of seed — the `mac_rs_valid=0` blocker that suppresses the Phase 2 source RID (and talker alias). The descramble now runs on the raw channel dibits before deinterleave/trellis, at each sub-frame's channel-bit offset into the continuous 4320-bit superframe sequence, matching the reference decoders (SDRtrunk `Timeslot.xor()`, OP25 `handle_packet()`). `ScramblerProbe` self-aligns the slot phase against the RS gate. Regression tests build a channel-scrambled burst from the reporter's confirmed MMR identity (WACN 0xBEE00 / System ID 0x164 / NAC 0x161) and recover the source RID through the full FEC chain; live-air confirmation of the per-slot offset is pending the reporter's `mac_rs_valid` re-pull. (issue #915, Finding B) - **TETRA AFC now acquires carriers several kHz off-centre.** The carrier AFC estimated the offset from the 4×Δφ differential mean, which wraps into ±f_sym/8 ≈ ±2250 Hz — so a control channel more than 2250 Hz off-frequency aliased into that window and left a multi-kHz constellation spin that broke Gardner timing, and the receiver never locked even on a clean, strong signal. Real SDR front-ends (RTL-SDR, Airspy, HackRF, non-GPSDO USRP) routinely sit several kHz off, so this was a real acquisition gap. A coarse mean-frequency stage (the angle of the pre-matched-filter block's lag-1 autocorrelation) now picks the alias bucket before the existing fine estimator refines it, extending acquisition to roughly ±6 kHz. (issue #940) - **TETRA control channels now lock on real air (§8.2.5 scrambler).** The scrambling LFSR shifted its register the wrong direction relative to the tap convention, so the generated sequence diverged from ETSI EN 300 392-2 §8.2.5 at the second bit. Because scramble and descramble share the generator, every encode→decode round-trip still matched and the coding tests stayed green, but no real, externally scrambled burst could be decoded: on air the unscrambled sync training sequence correlated while the scrambled BSCH never reached a CRC-clean codeword, so the colour code was never learned and the control channel never locked — the "18 000-baud symbol timing recovers but no CC lock" symptom, including on Single Carrier Base Station / dynamic-MCCH-sharing sites. Verified against a real 467.913 MHz SCBS capture, which now locks in ~11 ms and decodes MCC 250 / MNC 013 / colour code 0x2C. (issue #925) - **Eye diagram panel now follows the locked channel like the other Plots scopes.** The Eye diagram carried the offset/frequency tuning controls from the constellation work (issue #557) but, unlike the Constellation and Symbol scope panels, never refreshed the active-call snapshot — so its "follow the newest call" offset froze on a stale call the moment the panel was opened directly, and re-checking Hold left the view on that stale call frequency instead of parking it on the control channel. The panel now polls active calls while mounted and parks on the configured control channel on Hold, matching the sibling scopes. - **Completed-call `source_rid` is no longer set from an unverified P25 Phase 2 traffic-channel MAC PDU.** The in-call source RID is backfilled from the `GROUP_VOICE_CHANNEL_USER` MAC PDU decoded off the voice channel, but that MAC path runs with the outer RS(24,16,9) check off by default, so any 18 descrambled bytes parse — and a mis-framed / mis-descrambled superframe decoding random bytes occasionally lands on opcode `0x01`/`0x21` and injects a plausible-but-wrong RID indistinguishable from a real one (the source-side analogue of the `algorithm_id` smear above; the field report on MMR saw a near-uniform 234-of-256 MAC opcode distribution, i.e. garbage passing ungated). GopherTrunk now trusts a source RID only when its MAC PDU carries a valid outer RS parity, so a wrong RID is never carried to the webhook or `/api/v1/calls/history` — it stays omitted instead. Recovering the RIDs GT never frames on such systems is a separate Phase 2 superframe-framing fix (issue #915). On P25 Phase 2 (and Phase 1's residual-error tail) a bit-error in a traffic-channel Encryption Sync smears the decoded Algorithm ID roughly uniformly across `0x00-0xFF`, with a near-distinct Key ID per call. Those values were published straight to the completed-call webhook and `/api/v1/calls/history`, where they were indistinguishable from a real key (a 7-day MMR sample showed the real AES-256 `0x84` alongside a flat ~4-22 calls at *every* other algid). The composer now validates the decoded Algorithm ID against the TIA-102 registry (`p25.AlgorithmKnown`) before publishing, so an out-of-set value is dropped and the fields stay omitted rather than carrying garbage. Clear and every registered algorithm pass through unchanged. Refs #813, #924. - **The AACH is no longer mis-routed through the CMCE/MLE Layer-3 PDU parser.** The decoded AACH (a MAC-layer ACCESS-ASSIGN PDU) was being handed to `ParsePDU`, which is for CMCE/MLE Layer-3 PDUs, so it was mis-parsed (and could surface as a spurious grant/release). It is now parsed as the ACCESS-ASSIGN PDU it actually is. - **A TETRA channel recorded as a narrowband slice below the 144 kHz channel rate (e.g. the natural 48/50 kHz SDR++/SDRTrunk record rate) now decodes instead of emitting a nonsense symbol rate.** The `siglab` replay/analyze engine only ever *decimated* to the per-protocol channel rate — a capture already at or below the target was fed to the receiver raw. For TETRA (144 kHz target) that left a 50 kHz recording at 50000/18000 ≈ 2.78 samples/symbol, which the receiver rounded to 3 and handed the Gardner loop as a 3.0 nominal: an ~8% clock error it can't pull in, so it produced 50000/3 ≈ 16667 sym/s (−7.4%) and never locked. The down-converter now *normalises* to the channel rate in both directions — a sub-target capture is interpolated **up** to 144 kHz (`dsp.Resampler` already supports L>M), restoring the receiver's designed 8 samples/symbol. A 50 kHz capture now recovers 18004.9 sym/s (+0.0%) and the training sequences correlate; the committed 48 kHz `samples/tetra` reference likewise normalises to exactly 144000 Hz. Only sub-target replays change; every wideband SDR rate reduces exactly as before. - **The narrowband down-converter no longer shifts the channel rate on SDR sample rates that don't divide cleanly into the channel target.** When a capture rate reduced to a ratio past the resampler's L/M caps, `ccdecoder`'s DDC fell back to a crude integer decimator that missed the target rate by up to a few percent — e.g. a 3.019 MS/s stream landed the 144 kHz TETRA channel at 143762 Hz, i.e. 17970 sym/s instead of 18000 (−0.165%), a decode-breaking symbol-clock error. It now falls back to the closest L/M under the caps (the bounded search already used in the wideband `internal/dsp/tuner` path for issue #550), landing the same stream at 143998 Hz (17999.8 sym/s). Standard SDR rates (2.4/2.5/10 MS/s) reduce cleanly and are unaffected. - **Signal-Lab "Capture from tuner" hung on 30 s grabs and hid the recorded file.** A capture of N seconds spends ≥N seconds collecting IQ in real time before the handler writes anything, so a 30 s grab ran past the API server's 30 s write timeout and the response was torn down mid-write — the console sat on "Capturing…" forever (10 s worked). The capture handler now disables the per-request write deadline (as the SSE and audio-stream handlers already do), and the duration ceiling rose from 30 s to 120 s (bounded by a staged-file-size budget). The captures list also gained a persistent per-row **Download** link (previously the only link was transient state on the capture form, so it appeared to show up only after a second capture), and a long capture name no longer overflows the card and pushes the compare checkbox out of reach. - **Completed-call webhook now carries the source RID on nearly every call, not just the ~18% whose voice-side `call.source` decoded.** The per-call `broadcast.webhook` sink read the source RID off the grant that *bound* the call, but a call frequently binds from a source-less grant (a P25 Phase 2 compressed grant, or a `GRP_VCH_UPDATE` repeat) while the initiating `GRP_VCH_GRANT`'s RID arrives on a later repeat. That RID reached the engine but was never associated back to the running call. The engine now folds a later same-call grant's RID onto the bound call and republishes it through the existing source-update path, so the completed-call webhook (and the live SSE/TUI view) report the source. An in-call `call.source` update — which reflects the radio actually keyed on the traffic channel — still takes precedence, and the republish fires only on the first fill so the repeated control-channel grant stream never floods the bus (issue #915). Follow-up: on a heavily-compressed Phase 2 system a field test found that talkgroup-keyed matching alone reached only ~12% coverage, because the RID-bearing grant often arrives under a *different* talkgroup label than the source-less grant that bound the call (a mis-aliased compressed grant, or a super-group/patch remap). The engine now also recovers the source by physical channel: a frequency + timeslot hosts exactly one in-progress transmission, so a source-carrying grant landing on an active call's exact channel is folded onto that call regardless of its talkgroup label — which additionally suppresses the phantom duplicate call the mismatched talkgroup would otherwise spawn (issue #915). ### Changed - **Short digital recordings now report their frame yield.** The recorder's `recording shorter than call span` diagnostic gained `frames`, `audio_pct` (the fraction of the call span that decoded to audio), and `vocoder`, so a sparse-decode recording (few TCH/S bursts passing CRC — the TETRA short-recording symptom) is diagnosable from one log line instead of by diffing the WAV against the call record. - **Same-carrier voice-tap IQ drops are no longer silent.** When the control decoder's voice tap drops IQ to a lagging voice consumer (still dropping to protect the decode hot path), the dropped chunks are now counted and surfaced as a single warning at call end with the remedy — so starved voice decode (the short/gappy-recording symptom) is visible instead of silent (issue #402). - **Live captures stream straight to disk instead of buffering the whole grab in RAM.** The Signal-Lab capture path (and the `gophertrunk capture` / daemon `--iq-capture` subcommands) previously held the entire capture in memory as `complex64` and then allocated a second encoded copy, so peak memory scaled with `seconds × sample-rate` (~4.8 GB for 30 s at 10 MS/s). They now encode and write one chunk at a time through a shared streaming writer — narrowband slices too, via a stateful down-converter — so peak memory is a single chunk regardless of capture length, and maximum duration is bounded by disk, not RAM. ## [v0.7.1] — 2026-07-16 ### Fixed - **P25 Phase 1 recordings opened with a full-scale "startup scratch".** While the receiver is still acquiring symbol lock at the start of a transmission, the FEC layer resolves the marginal dibit stream to random-but-valid IMBE frames that the vocoder synthesised as a loud burst — where a reference decoder (TrunkRecorder) is silent. Measured across field captures, every call blasted the soft-limiter rail (~24000–26000) for the first ~0.15–0.55 s. The IMBE decoder now applies a **call-startup acquisition squelch**: it mutes output until a sustained run of stable-pitch voiced frames confirms real speech (acquisition garbage is idle / unvoiced / pitch-jumping), then releases for the rest of the segment. Muted frames are zeroed, not dropped, so the recording keeps its length. Enabled on the recorder/live path only (the raw decoder is unchanged). This is a heuristic — the true acquisition state lives in the receiver, not the vocoder frames — so a call that never presents a stable voiced run (e.g. unvoiced-only) opens muted up to a failsafe window. ### Security - **Go toolchain bumped to 1.25.12** to close `GO-2026-5856` — an Encrypted Client Hello privacy leak in the standard library's `crypto/tls`, which govulncheck flagged as call-reachable through the API TLS/gRPC servers and the rtl_tcp / import-client TLS paths. No source changes; the pin moves in `go.mod` and every CI/build workflow. ### Added - **Per-call demod quality (EVM / SNR) on P25 Phase 1 calls.** Each decoded P25 Phase 1 voice call now carries a measured RMS error-vector magnitude (`evm_pct`) and estimated symbol SNR (`snr_db`), surfaced in the call-history API and `call_log` alongside `signal_dbfs` — the demod-quality numbers to compare against another decoder (SDRTrunk). Measured over the settled decode from the receiver's soft/symbol taps and stamped onto `CallEnd`; nil for calls with no measurement (short blips, non-composer ends) and for the Phase 2 / DMR chains, which don't yet expose the taps (#878 follow-up). The gRPC `RIDCallRow` surface is unchanged (a separate proto change, as `signal_dbfs` also awaits). ### Fixed - **Extended the wideband voice-tap channel-select fix to P25 Phase 2 and DMR.** The same defect fixed for P25 Phase 1 — a wideband DDC voice tap feeding the receiver a ±24 kHz-wide stream with no channel filter (the front end was a pass-through no-op at the tap rate) — was present in the Phase 2 and DMR voice chains too. On DMR (4FSK/FM discriminator) a stronger adjacent channel could be captured and, because DMR talkgroup gating is disabled, recorded *as* the call. On P25 Phase 2 (linear H-DQPSK) adjacent energy instead pumped the AGC and degraded carrier recovery, raising EVM. Both chains now channel-select each wideband tap to ±6.25 kHz (half the 12.5 kHz channel spacing) before the receiver, matching Phase 1; the dedicated-tuner path is unchanged. - **Wideband P25 Phase 1 voice recordings came out short and garbled next to SDRTrunk** — on a busy multi-channel site a wideband DDC voice tap decoded clean *foreign* talkgroups mid-call (e.g. a 16 s over recorded as ~9 s of the wanted call spliced with dropped windows). The tap delivered a stream band-limited only to its ±24 kHz output Nyquist, and the voice chain's front end was a pass-through no-op at that rate — so the whole span, including the ±12.5 kHz adjacent channels, reached the receiver's FM discriminator. The capture effect then locked onto a stronger neighbour during the wanted talker's syllable gaps: those LDUs decoded as another talkgroup and were gated out (short recording), while the neighbour's energy raised in-band interference on the frames that *were* kept (garbled audio). The chain now channel-selects each wideband tap to ±6.25 kHz — half the P25 channel spacing — before the discriminator, dropping an adjacent channel ~80 dB while passing the wanted C4FM flat. The dedicated-tuner path (which already band-limits as it decimates) is unchanged. For diagnosing any residual truncation, the `composer: p25p1 decode quality` log gains a `gated_ldus` count (delivered LDUs the talkgroup gate dropped from the WAV), and the recorder now logs when a decoded call's audio runs materially shorter than its wall-clock span. - **Webhook payloads reported every call as `encrypted: false` and gave unit calls no `call_type`.** On systems whose encryption only resolves on the traffic channel (P25 Phase 2 compressed grants, Phase 1 LDU2 Encryption Sync), the recorder built its `CallComplete` from the grant-time session snapshot, which never saw the mid-call update — so the webhook said `false` (and never emitted `algorithm_id`/`key_id`) even though `/api/v1/calls/history`, fed from the engine-backfilled `CallEnd` grant, correctly showed the call encrypted. The recorder now mirrors the mid-call encryption / source facts onto its session grant, so every broadcast backend agrees with the call log. A new `call_type` field (`group` / `unit` / `data`, always emitted) lets a consumer tell a unit call — whose `talkgroup` carries a destination RID, not a talkgroup — apart from a group call, and unit-call `source` now populates when the RID resolves in-call (issue #897). - **SoapyRemote stream arguments smuggled through `sdr.soapy_remote[].args` were silently ignored.** Everything in `args` is passed to the remote SoapySDR `make()` call, but GopherTrunk builds the `SETUP_STREAM` frame itself, so `remote:mtu` / `remote:window` / `remote:prot` placed in `args` never reached the stream setup — a user who set `remote:mtu=8000` in `args` stayed on the 1500-byte default with no warning (issue #876). Config load now rejects those reserved stream keys in `args` and points at the dedicated `stream_mtu` / `stream_window` / `stream_protocol` keys that actually take effect. `docs/hardware.md` gains a field-tested networked-USRP-X310 example, a B210 `args` snippet, the `SOAPY_SDR_LOG_LEVEL=DEBUG SoapySDRServer` debug invocation, and guidance to prefer a manually chosen gain over AGC for survey work. - **Airspy on macOS aborted mid-stream with `usb: ReadPipe: 0xe00002eb`.** After a second or two of healthy decoding the bulk-IN pipe was aborted by macOS (`kIOReturnAborted`), the daemon retried, hit the same wall, and escalated to a fatal. It was **not** bandwidth — it reproduced at 2.5 MS/s (~10 MB/s) exactly as at 10 MS/s. The cause was the transport model: the darwin backend issued 32 *concurrent synchronous* `ReadPipe` calls on one pipe, which is not a supported IOUSBLib usage and macOS aborts under sustained streaming. The macOS bulk-IN path now defaults to **asynchronous** transfers (`ReadPipeAsync` serviced by one CFRunLoop thread, re-arming each transfer on completion) — the model libusb (and SDR#/SDRtrunk) use to stream the same hardware cleanly. `GT_USB_SYNC_BULK=1` restores the legacy synchronous reapers. Additionally, the Airspy driver now runs its real→IQ conversion on a dedicated goroutine instead of inline on the USB reaper, keeping the transfer servicing responsive. The daemon's retries-exhausted fatal also no longer double-prefixes the subsystem name (`widebandt2: widebandt2: …`). - **A flapping Airspy no longer kills the whole daemon.** After the silent-freeze fix, a macOS Airspy that keeps *recovering* — the IQ stream dies, the daemon reacquires and streams for a few seconds, then it dies again — still took the process down: the self-heal retry counter only reset after a single 60 s uninterrupted run, so those repeated ~seconds-long recoveries accumulated and the daemon escalated to a fatal shutdown after a handful of deaths (~30 s in). The wideband and control-channel retry loops now **decay** the counter after a run that streamed real data, so a recovering dongle self-heals indefinitely; only a device that re-dies immediately on every reopen (truly gone) escalates. - **The exact USB stream-death cause is now surfaced.** The error that killed the bulk-IN reaper (the stall watchdog's `usb: bulk-IN stream stalled`, a `usb: device disconnected`, or a wrapped per-URB error) was discarded, so the daemon only ever logged a generic “IQ stream closed unexpectedly.” The airspy and RTL-SDR drivers now record it and the `IQ stream died; retrying` log line names the concrete cause — the diagnostic needed to tell a stall from a disconnect from an overrun without guessing. - **Airspy on macOS no longer freezes silently.** After locking a control channel and decoding for a few seconds, an Airspy on macOS could go completely silent — the process stayed alive (heartbeats kept logging) but no IQ arrived and nothing decoded, with no error, EOF, or drop warning. The cause is a device-side USB endpoint halt that the darwin backend's blocking, no-timeout bulk read never noticed, so none of the existing safety nets (the overrun drop, the reaper-death `onStreamDead`, the enumerate-based pool watchdog) fired. A new **bulk-IN stall watchdog** now aborts a stream that delivers no data for `GT_USB_BULK_STALL_MS` (default 2 s) while the device is still present, converting the silent hang into a real end-of-stream; the wideband decoder is now supervised by a retry loop that reacquires the dongle and restarts (mirroring the control-channel decoder), so the daemon self-heals instead of quietly stopping. ### Added - **`dc_avoid` self-suggesting diagnostic on a poorly-decoding zero-IF control channel.** When a P25 control channel is locked but running at zero-IF (no DC-spike-avoidance LO offset) and its TSBK blocks are failing Viterbi/CRC at a high rate, the daemon now emits one advisory WARN pointing the operator at `dc_avoid: true` — the exact remedy for the on-DC front-end I/Q image / 1-f degradation. Advisory only (it never changes tuning), silent once `dc_avoid` is in effect, and fires at most once per lock session (issue #402). - **GopherTrunk Bundle (`.gtb.tar.gz`) — the capture-to-analysis case format.** A single portable archive that packages one SDR case — the raw IQ capture, an auto-carved narrowband DDC slice, logs, the SigLab signal analysis, the CryptoLab crypto analysis, and the hunt site/network mapping — under one human-and-machine-readable `MANIFEST.yaml` with per-file SHA-256, so a whole investigation is shared as one file and flows capture → SigLab → CryptoLab → back into `config.yaml`. New `gophertrunk bundle` subcommand (`pack`/`info`/`verify`/`extract`/`add`/`commit`), and `-bundle` on `capture`, `hunt` (mapping and `-survey-capture`), `analyze` (reads the capture and writes the result back), and `cryptolab assess` (tagged build). `commit` marks talkgroups encrypted from the bundle's crypto frames (algorithm named via P25 ALGID) before merging into `config.yaml`. An opt-in `-sigmf` sidecar makes the capture interoperable with SigMF tooling. The daemon serves `/api/v1/bundle/{info,verify,download}` (confined to `recordings.dir`), and the SigLab, CryptoLab, and main web consoles gain a **Bundles** view to inspect, verify, and download a case. See [`docs/bundle.md`](docs/bundle.md). - **Per-call received signal level in the call log.** Each recorded voice call now carries a `signal_dbfs` figure — the mean received channel power in dBFS, measured by the voice composer over the call's baseband IQ. It surfaces in the persisted `call_log` table and in `GET /api/v1/calls/history`. It is a channel-power / RSSI-style reading (0 = digital full scale; real signals negative), **not** calibrated absolute RSSI and **not** SNR/EVM. Calls ended without a measurement (watchdog timeout, preemption, shutdown) read `null`. Existing databases gain the column automatically on next open. - **USRP B210 (UHD) local-setup recipe.** `docs/hardware.md` now documents running `SoapySDRServer` on the same host and pointing the `soapyremote` driver at `127.0.0.1`, with the B210 master-clock / exact-rate, gain-in-tenths-of-dB, and antenna-via-`args` specifics. - **More USB debugging for the Airspy freeze.** With `RTLSDR_DEBUG_USB=1` the macOS backend now emits periodic bulk-stream telemetry (URBs, bytes, throughput, per-slot completion spread, idle gap) and a one-shot `bulk-IN stalled` line when the watchdog trips — enough to pin when and how a freeze happens. An opt-in `GT_USB_READPIPE_TIMEOUT_MS` switches the reaper to IOKit `ReadPipeTO` with a per-read no-data timeout as a candidate root fix. See `docs/reference/airspy.md` → Troubleshooting. - **Airspy vendor control transfers are now traced under `RTLSDR_DEBUG_USB=1`.** The Airspy shares the RTL-SDR USB transport but never wrapped it for debug, so its control setup (`SET_SAMPLERATE` / `SET_FREQ` / `RECEIVER_MODE` / gain) was invisible even with USB debugging on; it is now wrapped like the RTL-SDR path. ## [v0.6.4] — 2026-07-08 ### Fixed - **DMR (and single-dongle P25) voice now decodes with no extra config.** A `role: wideband` dongle produced talkgroups/RIDs but no audio and no recordings, because voice grants were dropped for lack of a voice device: the voice pool is fed only by physical `role: voice` SDRs or virtual `voice_taps`, and `voice_taps` defaulted to 0. When trunking is configured and no physical `role: voice` SDR is present, the daemon now auto-enables a couple of virtual voice taps on each wideband dongle so voice decodes out of the box; set `voice_taps` explicitly to control concurrency. The shipped DMR samples now set `voice_taps`, the "no voice source" startup warning names the fix, and the config-builder's stale "voice taps (0–8) / capped at 8" hint is corrected. ## [v0.6.3] — 2026-07-07 ### Added - **Neighbour sites on `GET /api/v1/sites`.** Each `SiteDTO` now carries a `neighbors` array — the adjacent sites the site advertised over the air (P25 Adjacent Site Status Broadcast, opcode `0x3C`), each with the neighbour's RFSS/Site and its band-plan-resolved control-channel `downlink_hz` (and `uplink_hz`). This is the same decoded data already surfaced on `GET /api/v1/systems`, now attached to the camped site of each system on the per-site endpoint, so a consumer can backfill the wider network's control-channel map from a single camped site without directly decoding every site. Empty on sites that did not broadcast a neighbour list (issue #864). ### Fixed - **Live browser audio no longer requires a recordings directory.** The voice composer and its decoded-PCM tap were gated on the file recorder, which needed `recordings.dir`; a listen-only setup got silence. The recorder now supports a decode-only mode (decodes and streams live audio without writing files). - **Live audio was silent in the browser on Windows.** The Web Audio player pinned its `AudioContext` to 8 kHz, which Windows/WASAPI often accepts yet renders as silence in every browser. It now runs at the device-native rate and upconverts the 8 kHz stream with the existing band-limited resampler. - **RadioReference *sites* CSVs now fail with an actionable error, and SOAP faults surface their fault string.** The importer only recognises RadioReference's native *talkgroups* CSV and the multi-section bundle, so a native *sites* table export fell through to the bundle parser and failed with the opaque "data at line 1 before any # Section marker". A sites-shaped CSV (no `# Section` markers, a header with a frequency column plus a site/RFSS/NAC column) is now detected and rejected with a message pointing at the formats that work today (system PDF, Talkgroups CSV, or a bundle). Separately, when RadioReference answers a browse call with an HTTP 500 SOAP fault, the client now surfaces the concise `` instead of dumping the raw XML envelope (issue #849). ## [v0.6.2] — 2026-07-04 ### Fixed - **P25 Phase 2 superframes now lock on real off-air traffic.** The outbound sync constant was a garbled 48-bit value (`0x575F7DFF77FF`) used where a 40-bit (20-dibit) sync word is expected, so the top byte was silently dropped and the detector matched a pattern that never appears on air — `superframes=0` on every call, so `algorithm_id`/`key_id` never populated. Differentially decoding the spec-conformant outbound sync (`0x575D57F7FF`, TIA-102.BBAC) yields the 20-dibit stream the detector must match; setting it (plus a width guard) lets superframes lock. Verified against a spec-conformant synthetic; issue #813 stays open pending the reporter's real capture (issue #813). - **`control_channel_carrier_offset_hz` now reports the *total* control-channel offset, matching the WARN log.** The field on `GET /api/v1/sites` was wired to the receiver's residual AFC only, so with `sdr.autotune` on — once a correction folds into the DDC and the receiver re-centres — it under-reported the offset the "carrier offset" WARN was still flagging: the log and the API disagreed. The published value now adds the applied DDC correction to the residual, so the two agree by construction. With autotune off (the default) the applied correction is 0 and the value is unchanged (issue #815). ## [v0.6.1] — 2026-07-03 ### Docs - **Lab Bench tutorial blog series.** Three concurrent hands-on series — **Signal Lab**, **RF Scope**, and **Crypto Lab** — launch on the docs blog, built on a shared no-CDN, theme-aware, high-DPI canvas charting module (constellation, eye, heatmap, line, bars, timeline) that renders inline figures from post JSON, with per-series hub pages, trilogy cross-links, and Blog-group navigation. ## [v0.6.0] — 2026-07-02 ### Added - **Every browser console is reachable from the daemon, and they share one design system.** The daemon previously mounted only the operator console (`/`), Config Builder (`/config/`), and Crypto Lab (`/cryptolab/`); Signal Lab and RF Scope were reachable only via their standalone `serve` commands, absent from the nav, and Crypto Lab's header linked to a `/siglab/` path the daemon 404'd. Signal Lab now mounts at `/siglab/` and RF Scope at `/rfscope/`, both advertised via `RuntimeDTO` so the nav only links to what is actually reachable, and every sub-app gains a shared nav back to the operator console and its siblings. RF Scope also joins the shared `--gt-*` design tokens (dark/mono/light themes + reduced-motion), so all consoles look and behave alike. - **Per-site P25 decode quality on `GET /api/v1/sites`.** Each `SiteDTO` now carries `control_channel_tsbk_error_rate` (cumulative % of TSBK blocks failing Viterbi/CRC on that site's control channel), `control_channel_tsbk_count` (the attempts behind it, a confidence weight), and `control_channel_decode_quality` (`clean` / `marginal` / `poor`). This exposes decode health independently of carrier lock — a well-locked carrier at range can still error heavily — so consumers can categorise sites as clean vs marginal rather than approximating from carrier offset alone. Fields are omitted until a TSBK is decoded and on non-TSBK Phase 2 paths (issue #858). ### Fixed - **Airspy overrun no longer silently freezes the whole decode.** Under high load the wideband DSP consumer could stall, the driver's IQ channel filled, and the blocking send stopped the USB reaper from posting new reads — the endpoint FIFO overflowed and the stream halted with no error or log, which also froze the control-channel decode (field-reported on an Airspy R2 at 2.5 and 10 MS/s when a voice grant spun up a second IQ consumer). The Airspy deliver path is now non-blocking and drops on overrun — matching the RTL-SDR and SoapyRemote paths — counting the drop via `iq_underruns_total` and emitting a rate-limited "host can't keep up — lower `sdr.sample_rate`" WARN, so the reaper always cycles back and the device never underflows. - **Strong in-channel signal with no sync now warns and points at `sdr.ppm`.** A narrowband C4FM carrier that is present but off-frequency (an uncorrected RTL-SDR tuner offset beyond the demod's ~±75 Hz tolerance) decoded nothing with no indication why. The wideband engine now emits a per-channel "strong signal but no sync" WARN when a channel holds strong power with zero sync/FEC across several diagnostics windows, naming `sdr.ppm` as the likely fix; the dmr-simplex sample config's ppm note is strengthened accordingly (issue #836). - **RadioReference CSVs exported through Excel on Windows now import.** Such files carry a UTF-8 BOM (`EF BB BF`); the raw bytes reached the format sniffer, mangling the first header cell so the native-CSV detector failed and import errored with "native RR CSV header missing decimal/dec column" or "data at line 1 before any # Section marker". A leading BOM is now stripped at the file-read site (covering both the CLI import and the web-UI upload) and, defensively, in the reader-based entry points (issue #849). ## [v0.5.9] — 2026-07-01 ### Added - **Full-spectrum survey — scan the whole device, name everything, capture any signal.** `gophertrunk hunt -survey` gains an end-to-end "what's on the air" workflow for undocumented, protocol-dense areas: - **`-whole-device`** sweeps the SDR's *entire* tuning range (derived from the device itself — 24 MHz–1.766 GHz for an R820T, etc.) instead of a hand-typed `-band`, printing the resolved span, step count, and ETA. A new optional `sdr.FreqRanger` reports each backend's range; every tuner single-sources its PLL bounds so the advertised and enforced ranges can't drift. - **Wideband detect + name + stitch.** `-detect-wideband` (auto-on with `-whole-device`) finds signals far wider than a channel — cellular LTE/5G, WiFi, other OFDM — by detecting occupancy plateaus and *stitching them across tunes* to recover the true occupied bandwidth of a signal wider than the SDR's instantaneous bandwidth. These are surfaced as **named-but-not-decoded** rows: GopherTrunk identifies and can capture them, it does not demodulate them. - **Unified inventory row.** Every detected signal now carries a **name**, **frequency**, **service/purpose** (from a curated frequency-allocation table — FRS/GMRS, amateur, public-safety, ADS-B, ISM, the cellular bands, …), signal **quality**, **encrypted?** and **encryption type**, and a **wideband** flag. Encryption surfaces from the decode (ALGID → AES-256, ADP/RC4, …); a new `-detect-encryption` entropy triage flags *unknown* digital bursts that look encrypted. The survey CSV gains the matching columns. - **List-driven capture → SigLab / CryptoLab.** From a survey's JSON, `gophertrunk hunt -survey-capture -from survey.json -to siglab|cryptolab` records raw IQ of the chosen signal (frequency + occupied bandwidth drive the capture) and hands it on — SigLab identify, or an rfscope pass that emits a CryptoLab `ks` frames file. The same action is a per-row button in the web **Hunt** tab, an `↑/↓` + `c`/`C` keybinding in the TUI Hunt panel, and `POST /api/v1/hunt/capture` on the daemon. ### Fixed - **Airspy R2/Mini reported half its true IQ rate, so wideband decoded nothing.** The driver treated the firmware's `GET_SAMPLERATES` table as 2× "device" rates and reported half the rate the hardware actually delivers, so `ActualSampleRate()` returned e.g. 5 MS/s for an R2 streaming 10 MS/s. The daemon then built its wideband down-converter at half rate (logging the spurious "SDR streams a different sample rate than requested" warning, issue #402), the symbol clock ran 2× off, and no packets decoded. The table is in IQ output rates (R2 `{10, 2.5}` MSPS, Mini `{6, 3}` MSPS); the driver now matches the requested IQ rate against it directly. The IQ converter is unchanged — the firmware still streams the real ADC at 2× and the host decimates back down. The same bug also broke the **control channel on an R2 at 2.5 MS/s** (`sample_rate: 2500000` → `actual_hz=1250000`, continuous `no FSW hits`); the daemon now builds the control down-converter at the true 2.5 MS/s so the symbol clock stays aligned (#851, same root cause as #764). ## [v0.5.8] — 2026-06-30 ### Added - **RF Scope — protocol-agnostic RF network analysis ("Wireshark for the RF physical layer").** A new `gophertrunk rfscope` surface analyzes any band — a recorded IQ capture or a live SDR — with no prior knowledge of the technology, modulation, framing, or encryption, and produces a structured *scene*: an RF protocol hierarchy, per-channel I/O-graph activity, burst timing/periodicity, an emitter/conversation graph (frequency hoppers collapse into one emitter; it defers to `hunt`'s authoritative map when a control channel is present), an entropy/encryption triage built on the cryptolab randomness battery, and an expert-info anomaly list. It ships as a CLI (`rfscope analyze`/`live`/`list` with summary/JSON/JSONL/YAML/CSV export), a Bubbletea cockpit (`rfscope cockpit`, `rfscope live -tui`), and a standalone web console (`rfscope serve`, built with `make rfscope-web-build`). Unidentified digital payloads can be emitted as a cryptolab `ks` frames file (`-frames-out`) that feeds `cryptolab classify auto` / `ks reuse` directly. - **cryptolab external known-key decrypt (no brute required).** When an operator has already recovered an unbundled cipher's key (e.g. ran their own TEA1 brute), `assess crypto` now verifies it through the external cipher and decrypts + grades the capture by passing the key via `-keys` with `-extern-cmd` / `-extern-algid` and a known-plaintext oracle — the weak-key method reports the verified break, so `-brute-bits` is only needed when the key is still unknown. - **cryptolab recipe split-band & rolling descramblers.** The `recipe` pipeline gains `descramble-splitband` (`split` fraction of Nyquist) and `descramble-rolling` (`frame` samples, `schedule` of split fractions or `auto`) ops, bringing the inline analog-voice descrambler steps to parity with the standalone `descramble` tool's three modes. The web Recipe Builder renders the new float `split` parameter. - **Crypto Lab is discoverable from the other web consoles.** In a `-tags cryptolab` daemon build the main GopherTrunk console shows a *Crypto Lab* nav entry and the Signal Lab header shows a 🔐 Crypto Lab link; the Crypto Lab console links back to both. All gated on a new `runtime.cryptolab_console` flag (`GET /api/v1/runtime`), so the links appear only when the console is actually mounted — default builds and standalone siglab never show a dead link. - **cryptolab external-cipher bridge (TEA1 et al.).** `assess crypto` and the `recipe` pipeline can now drive an operator-supplied cipher program as a subprocess (`engine/extcipher`), so unbundled ciphers — most importantly the TETRA TEA1 32-bit backdoor brute — are fully runnable by pointing the harness at a vetted tool, without GopherTrunk shipping a cipher it can't verify or whose only reference is licence-incompatible. `assess` gains `-extern-cmd` / `-extern-algid` (brute the backdoor natively in the plugin, then verify + decrypt + grade); the recipe pipeline gains an `extern-decrypt` op. Both are CLI-only — the web recipe endpoint refuses host-execution ops (HTTP 403). - **cryptolab web Recipe Builder.** The cryptolab console gains a *Recipe Builder* tab that drives the `recipe` pipeline interactively (config-builder style): pick an input, assemble an ordered operation list from a palette with per-step parameters and move/duplicate/remove, run it server-side, and read the per-step report plus the final bytes (with download). Backed by new `GET /api/v1/cryptolab/recipe/ops` and `POST /api/v1/cryptolab/recipe` endpoints. - **cryptolab DMR decryption + generic RC4.** `assess crypto -protocol dmr` now actively attempts DMR privacy with the real cipher cores (RC4 "Enhanced Privacy", DES/3DES/AES-OFB) keyed with operator-supplied material, and the recipe pipeline gains a generic `rc4-decrypt` op. DMR's vendor key/IV derivation is the analyst's to supply; the cipher cores are bundled. - **cryptolab security-test suite** (`-tags cryptolab`). New analysis and active-attack tools, ported from the industry crypto-tool landscape and optimized for RF: - `assess crypto` — a security-test harness that attempts to decrypt captured encrypted frames by every applicable method (cipher-strength, a cross-protocol P25/TETRA/DMR known-weakness advisory, IV reuse, known-plaintext, default/weak keys against the real ADP/DES/3DES/AES ciphers, reduced-keyspace brute force, keystream-LFSR prediction) and grades each, with an overall `RESISTANT` / `PARTIAL` / `BROKEN` verdict. A verified complete decryption means the deployment failed the test. The advisory surfaces published breaks even for unbundled ciphers (e.g. the TETRA TEA1 32-bit backdoor, CVE-2022-24402). - `randomness battery`/`quick` — NIST SP 800-22 statistical randomness tests on a keystream/payload bitstream (strong vs. structured). - `classify auto` — triage an unknown payload's obfuscation class and recommend the next tool. - `ks reuse`/`mtp`/`extract` — keystream-reuse / many-time-pad recovery (P25 OFB/ADP keystream reuse via a repeated Message Indicator). - `stats period` — autocorrelation period detection and repeated-n-gram histogram. - `recipe run` — a CyberChef-style operation pipeline: chain byte transforms (XOR, the real ADP/DES/3DES/AES cipher decrypts, bit reversal, spectral inversion, hex/base64) and analyses (entropy, randomness) from a JSON/YAML recipe, piping the bytes between steps. - **Live decoder → cryptolab crypto-frame bridge.** Setting `recordings.crypto_capture_path` makes the P25 Phase 1 voice composer append each encrypted superframe's Message Indicator + encrypted voice frames as JSONL for `cryptolab assess`/`ks`. Off by default (no effect on the voice path when unset). ## [v0.5.7] — 2026-06-29 Headlined by a **P25 Phase 2 voice-decode fix**: the H-DQPSK receiver now recovers the carrier frequency, so the Phase 2 voice follower locks superframes on ordinary non-TCXO tuners (e.g. RTL-SDR) instead of only on a TCXO-clocked Airspy. Rounded out by live-audio quality fixes — decoded voice fades cleanly at call end, the WebUI plays one call at a time instead of mixing overlapping talkgroups, and recording filenames render in the configured display timezone — plus a spectrum-panel follow-state fix. ### Added - **P25 Phase 2 per-call MAC census** (#813). An end-of-call census logs once per voice chain — superframes locked, a per-`SlotType` subframe histogram, MAC subframes seen, and MAC PDUs decoded — so a silent decode pins exactly which pipeline stage (superframe sync / ISCH classification / MAC FEC) produced nothing on real air. Telemetry only; no protocol/FEC/geometry logic changes. - **"Solution Postmortem" blog category** on the docs site (#808). ### Changed - **Recording filenames render in the display timezone**, not always UTC. The per-call WAV basename now honours `display.timezone` — a configured non-UTC zone gets a filename-safe numeric offset like `+1000`, while UTC still formats as a literal `Z`, so existing filenames are unchanged. ### Fixed - **P25 Phase 2 H-DQPSK carrier-frequency recovery** (#813). The Phase 2 voice follower never locked a superframe on real traffic (a reporter saw `superframes=0` on 67/67 calls) even though the same RTL-SDR decodes Phase 1 fine: the H-DQPSK receiver had no carrier-frequency recovery, so a real tuner's residual offset rotated every symbol out of its quadrant and the outbound sync never correlated. The receiver now does a one-shot coarse carrier seed → NCO de-rotation → AGC → Gardner → Costas fine loop (on the `ClockGardner` path only; clean zero-offset streams stay a no-op), and the Costas lock phase is now rotation-aware for the π/8 H-DQPSK constellation. Fixes both the voice follower and the control-channel path. - **Decoded digital voice fades to zero at call end** (P25/DMR). Short transmissions ended on a hard waveform discontinuity — an audible scratch/click — because digital voice, unlike the analog FM chain, had no end-of-call ramp. A call now appends a short (~10 ms) linear fade to both the recorded WAV and the live decoded fan-out; silent/dead-key calls are a no-op. - **WebUI live audio plays one call at a time.** The browser player opened the audio stream unfiltered, so every concurrent voice call's PCM arrived interleaved into one body and talkgroups overlapped. The player now follows the primary (most recently started) active call and filters the stream to its device, re-pointing as the primary changes so audio switches cleanly between calls — standard scanner behaviour. - **Spectrum panels keep their follow state live** (#810). The Mixer/Tuning/Histogram/Constellation/SymbolScope panels read active calls from the shared store but nothing refreshed it off the Active/Dashboard routes, so opening a panel directly froze it on a stale "following call". Each panel now polls active calls itself, and re-checking Hold parks the view on the control channel instead of a possibly-stale call frequency. ### Internal - End-to-end regression test for the adjacent-carrier offset WARN through the real `ccdecoder.Decoder` (#815), guarding the live behaviour the reporter confirmed — a control lock ~12.5 kHz off-frequency tripping the WARN — which the existing stubbed tests and the `replay` path did not exercise. ## [v0.5.6] — 2026-06-28 Headlined by the **`cryptolab` cryptographic-research toolkit** — a new, opt-in subcommand and browser console for byte-oriented cipher research — and by broader P25 visibility in the live views: the decoded **IDEN_UP frequency-band table** and **every active talkgroup** (not just the calls a tuner is following) now surface in the Systems and active-call panels, Phase 2 encrypted calls populate **ALGID/KID** off the on-air MAC_PTT slot, and the control decoder now **warns when it has locked onto an adjacent site off-frequency** instead of silently decoding the wrong site. ### Added - **`cryptolab` — optional RF cryptographic-research toolkit**. A new, opt-in `gophertrunk cryptolab` subcommand collects byte-oriented research tools — statistical triage (entropy / index-of-coincidence / chi-square / XOR key-length), classical-cipher brute force (single/repeating XOR, Caesar, and Vigenère with English and crib scoring), LFSR/keystream analysis (Berlekamp–Massey, keystream extraction), CRC parameter recovery, and analog voice spectral descrambling — plus a pluggable subject framework for studying byte-oriented obfuscators, with an initial length-seeded-obfuscator recovery suite (gauge sweep, structure/wiring enumeration, a monotone+resumable cell solver, and from-seed simulation). It is **excluded from the default build** and linked in only with `make build TAGS=cryptolab` (the same opt-in pattern as the DVSI vocoder). A browser console (`gophertrunk cryptolab serve`) mirrors the `siglab`/`configbuilder` consoles — a schema-driven form exposes every tool, mode, and setting, streams the live run log, and renders the structured result with downloadable artifacts. See `docs/cryptolab.md`. - **`cryptolab` toolkit expansion**. The `brute` tool gains a **monoalphabetic substitution** solver (frequency-seeded hill-climbing with random restarts, scored by an embedded English trigram model). The `descramble` tool gains **split-band** inversion (independent low/high sub-band inversion about a configurable split) and **rolling-code** inversion (per-frame split schedule, with `auto` detection of each frame's inversion). The web console is now also **mounted inside the main daemon at `/cryptolab/`** when `gophertrunk` is built with `-tags cryptolab`, so it is reachable from the running daemon without a separate `cryptolab serve`; the default daemon build is unaffected. - **P25 IDEN_UP frequency-band table in the live views.** The full P25 IDEN_UP / identifier-update band table GopherTrunk already decoded (and rendered in the network report) now overlays onto `GET /api/v1/systems` and the web Systems detail modal as a "Frequency bands (N)" section beside the neighbour-site list. Operators can read the band plan SDRtrunk-style and map a neighbour's channel id/number back to an absolute frequency without doing it by hand. Values come from the same live topology snapshot as the report, so they match exactly (TDMA flag and transmit offset included). - **All active talkgroups surfaced, not just tuner-bound calls.** The active panels previously showed only calls bound to a voice tuner, so a single-voice- tuner setup could only ever display one active talkgroup even when several calls were up. A control-channel activity tracker now records every voice grant the control channel announces; the active-calls API merges tuner-decoded and control-channel-observed calls with a `following` flag, and the TUI and web active panels render observed-only calls distinctly. Audio is still limited by tuner count — the display of who's *up* no longer is. - **P25 Phase 2 ALGID/KID from the on-air MAC_PTT slot** (#813). Real Phase 2 systems carry the encryption sync (ALGID/KID/MI) in the MAC_PTT message that begins each transmission, keyed by slot type rather than a MAC opcode, so the previous opcode-keyed accessor never matched on air and encrypted calls left `algorithm_id`/`key_id` blank. PTT-slot PDUs are now decoded and routed through the existing call-encryption backfill, and the per-channel MAC census logs the payload hex for field confirmation. Flagged a working model pending on-air validation; degrades gracefully if the layout is wrong. - **Cryptography & cryptanalysis field-guide category and a dedicated SigLab user guide** (docs). New cryptanalysis-method reference pages plus a standalone Signal Lab user guide, with a broken link fixed. ### Fixed - **Adjacent-site off-frequency control lock now warns** (#815). A strong adjacent-site P25 control carrier ~12.5 kHz away can bleed through the narrowband receiver, lock, and decode the *neighbouring* site's identity while still reporting the tuned frequency — with no indication anything is wrong. While locked, GopherTrunk now emits a throttled WARN when the total carrier offset from the configured frequency exceeds a threshold (default 4 kHz, `sdr.carrier_offset_warn_hz`), naming both plausible causes (adjacent-site lock or a mistuned oscillator). Advisory only — it never retunes — and the live offset is surfaced on `GET /api/v1/sites` so the mismatch is visible without dropping to capture/spectrum. ## [v0.5.5] — 2026-06-27 A reception-and-robustness release for wideband and DMR captures, plus the talker-alias cryptanalysis writeup. **Tier II conventional DMR now decodes 2-slot interleaved voice by default** (#644), the **wideband voice-tap DDC is span-aware** so grants out at the band edges actually tune, and **SoapyRemote overruns are surfaced and shed** instead of silently shredding every channel. ### Added - **2-slot interleaved voice by default for Tier II conventional DMR** (#644). A DMR repeater carrier is 2-slot TDMA, so the single-slot voice decoder sliced alternating timeslots into each superframe — the "DJ scratch", garbled- audio signature from the field report (every quality line showed `lc_superframes=0` with real, FEC-clean AMBE). `proto=dmr-tier2` conventional systems now default to the 2-slot interleaved cadence so the slot router binds and audio reconstructs cleanly. ### Changed - **Span-aware voice-tap DDC so outer wideband grants tune.** A wide DMR plan on a 10 MS/s capture has voice grants out past ±1.9 MHz; the virtual tuner advertised the full IQ window and the engine bound those grants, but the per- call DDC was built without span and rejected any offset beyond ±1.1 MHz, so the outer carriers produced no audio. The per-call bank is now sized from the tap offset (the span-aware path #764 added for the control channel), so any grant inside the advertised band actually tunes. - **SoapyRemote overruns are surfaced and drained without stalling the radio.** At high sample rates a blocked reader stopped draining the socket and stopped sending flow-control ACKs, so the device overflowed and dropped samples, shredding every channel's framing at once — with only an invisible per-datagram DEBUG line as a signal. Overruns are now reported at a rate-limited WARN with running counts and an actionable hint, and the read loop never blocks (it sheds the oldest queued chunk and counts it), keeping the socket drained and ACKs flowing. The channelizer per-tap loop is also parallelized across CPU cores, and an oversampled capture rate now draws a lower-the-rate advisory at startup. ### Docs - **Talker-alias cipher cryptanalysis** (#773): a chosen-plaintext capture procedure and the documented cryptanalysis findings (product-form decode decomposition), linked to the issue thread. ## [v0.5.4] — 2026-06-26 A wideband-robustness and capture-guidance release. Multi-tap wideband front ends get **`gain: "auto"` guidance** and can now **host dense plans (70-DMR) instead of crashing at init**, `gophertrunk capture` warns on **over-high sample rates**, talkgroups **auto-populate from live control-channel grants**, and the unverified Motorola talker-alias cipher is **gated with corrected provenance** (#773). ### Added - **Capture sample-rate guidance** (#771). `gophertrunk capture` now prints a one-line hint when a high native rate (>4 MS/s) is chosen for a narrowband trunking capture, explaining that the down-converter normalises to 48 kHz and that the top native clock on wideband front-ends (e.g. Airspy R2 at 10 MS/s) can degrade in-channel SNR via phase noise / reciprocal mixing — the verified root cause of the #771 no-lock report. A new "Choosing a sample rate" section in `docs/hardware.md` documents the finding and recommends ~2.4–2.5 MS/s for control-channel roles. - **Auto-discover talkgroups from control-channel grants.** The Talkgroups database previously loaded only from a hand-maintained CSV, so an operator starting empty saw bare numbers. Each talkgroup is now catalogued the first time it is granted on the control channel (skipping unit-to-unit / interconnect grants), tagged "Discovered" and surfaced in Database → Talkgroups. In-memory only and never silently widens a curated scan list — the operator opts a learned talkgroup in from the UI. ### Changed - **`gain: "auto"` guidance for multi-tap wideband devices** (#749). A single fixed gain on a shared front end can't serve sites of differing strength — picking a gain so the strongest site doesn't clip leaves weaker co-tenants at the ADC noise floor. GopherTrunk now WARNs at startup when a `role: wideband` dongle with >1 channel is pinned to a manual gain (and the per-tap low-power WARN suggests `gain: "auto"` too), with the AGC recommendation documented in `config.example.yaml` and `docs/hardware.md`. - **Dense wideband plans are hosted on the channelizer instead of crashing at init.** Very dense wideband plans (e.g. 70-DMR) now stay on the channelizer (a per-tap DDC is too heavy at that scale) so they initialise and run, and the web plots follow the voice channel on wideband SDRs. ### Fixed - **Gated the unverified Motorola talker-alias cipher; corrected false provenance** (#773). RIDs resolve on live Phase 2 traffic but no talker-alias *text* ever decodes: the per-byte substitution cipher was AI-authored in #376 yet its comments claimed it was a reverse-engineered fact "identical across every open-source decoder." A clean-room attempt showed the cipher is mathematically underdetermined from the one partial capture available, and SDRtrunk's GPLv3 table is off-limits to Apache-2.0 GopherTrunk. The misleading provenance is corrected to the truth (structure inferred from the public #773 protocol work) and the unverified decode is gated rather than shipping fake confidence. ## [v0.5.3] — 2026-06-25 Headlined by **neighbor (adjacent) sites in the live views** — the P25 Adjacent Site Status Broadcasts (opcode 0x3C) GopherTrunk already decoded now surface in the always-on monitoring surfaces (decoded-message log, the TUI and web Systems panels, and the `/api/v1/systems` responses), not just the drill-in report. It also lands a **configurable display timezone** (`display.timezone`) that renders every human- and machine-facing timestamp in the host's local time by default (overridable to UTC or any IANA zone), and an **opt-in voice enhancement chain** (`recordings.enhance`) that trades a little faithfulness for the cleaner, louder "sound-good" audio the rival decoders produce — band-limiting + uniform loudness, shaping both recordings and live audio from a single point. Rounded out by a power-complementary unvoiced-synthesis window that removes a buzzy ~7 dB tremolo from the IMBE/AMBE+2 vocoders (#644). ### Added - **Neighbor (adjacent) sites in the live views.** GopherTrunk already decoded P25 Adjacent Site Status Broadcasts (opcode 0x3C) but only surfaced them in the system drill-in report and `gophertrunk replay`. They are now visible in the always-on monitoring surfaces: the decoded-message log prints an SDRtrunk-style "Neighbor Sites" block (deduplicated — re-emitted only when the adjacent-site set changes), and the TUI and web Systems panels gain a neighbour count column plus a neighbour list in the system detail. `GET /api/v1/systems` and `/api/v1/systems/{name}` now carry a `neighbors` array (RFSS/Site with band-plan-resolved downlink/uplink frequencies). - **Configurable display timezone** (`display.timezone`). "Local" (default), "UTC", or any IANA name (e.g. "America/New_York") for the human-facing timestamps. - **Opt-in voice enhancement chain** (`recordings.enhance`). A "sound-good" post-vocoder chain for decoded digital voice — rumble high-pass (~250 Hz), warmth high-shelf, telephone-band low-pass (~3.4 kHz, like OP25), a louder AGC target (22000 vs the faithful 18000), and an optional soft-knee compressor. It deliberately trades a little faithfulness for the cleaner/louder sound the rival decoders produce: OP25 band-limits its output, Trunk Recorder ships loudness normalization on by default, and DSD/DSDPlus run an aggressive output AGC — uniform loudness + band-limiting, not exotic EQ, is what makes them sound "better" (SDRtrunk, by contrast, plays raw vocoder output and has open requests for exactly this). Because the recorder decodes each call once and fans the PCM out to disk and live monitoring, the chain shapes **both** recordings and live audio from a single point. Off by default — the faithful path stays byte-identical — and surfaced near the top of the Config Builder. Toggling `recordings.enhance.enabled` also live-applies via `PATCH /api/v1/settings` (takes effect on the next call). Subsumes `warm_dmr_audio` (now superseded) by extending the warmth shelf to all protocols. - **Opt-in "warm" DMR voice decoder** (`recordings.warm_dmr_audio`, #644). A gentle output high-shelf (≈2 dB cut above 1.5 kHz) that softens the bright/thin "digital" timbre of software AMBE+2 decode, selectable via the new `ambe2-dmr-warm` vocoder. It is a listener tone preference, not a codec-quality fix — the residual synthetic character of low-bitrate AMBE+2 is intrinsic to software decoders (mbelib, SDRtrunk's JMBE, and GopherTrunk all share it); only a DVSI hardware vocoder removes it. Off by default; DMR only. ### Changed - **Timestamps now render in local time by default**, not UTC, across every human- and machine-facing surface. The decoded-message log, power log and TUI panels (events / dashboard / tone alerts) previously forced GMT+0 ("…Z"); the JSON/gRPC API (RID activity & history, active calls, location reports, the `/health` daemon time), webhook payloads and rdioscanner uploads did the same. All now render in the host's local timezone, overridable via `display.timezone` (including back to "UTC"). The API/webhook/rdioscanner timestamps stay RFC3339 but now carry an explicit numeric offset (e.g. `+02:00`, or `Z` when UTC), so they remain an unambiguous, parseable instant. ### Fixed - **Unvoiced-synthesis tremolo in the MBE vocoders** (#644). The §6.4 unvoiced overlap-add used a Hann window that is not power-complementary at the 160-sample hop, modulating the synthesized noise floor ~7 dB at the 50 Hz frame rate (a buzzy artifact, most audible on noisy/fricative speech). Replaced it with a power-complementary tapered-cosine window so the noise floor is flat. Shared by the IMBE (P25) and AMBE+2 (DMR/NXDN/dPMR) paths. ## [v0.5.2] — 2026-06-24 A maintenance release headlined by a **generic JSON webhook call sink** — POST one stable-schema JSON object per completed call to any HTTP endpoint, so GopherTrunk feeds custom dashboards and automations alongside the existing OpenMHz / Broadcastify Calls / RdioScanner / Icecast backends. The rest is voice-path robustness: DMR 2-slot cadence is now picked by AMBE FEC instead of the unvalidated embedded Link Control (fixing garbled "sounds-encrypted" audio on 288-cadence carriers, #644), calls that never deliver a single voice frame end at a bounded 7 s window instead of hanging on the 30 s watchdog (#788), and siglab now names *why* a control channel did not lock with an opt-in soft-sync fallback for marginal captures (#771). ### Added - **Generic JSON webhook call sink** (`broadcast.webhook`, #404, #268). A new outbound call-streaming backend that POSTs one `application/json` object per completed call with a documented, stable schema: system, protocol, talkgroup (+label), source RID, frequency, P25 site identity (channel/RFSS/site/NAC), timeslot, encryption + emergency flags, patched groups, and RFC3339 start/stop timestamps. An optional `Authorization` header is sent verbatim and `include_audio` opt-in embeds the base64 MP3 (default off keeps it a lightweight metadata feed). Reuses the existing Manager fan-out (system filter, min-duration gate, bounded exponential-backoff retry); configure under `broadcast.webhook[]` and in the web Config Builder. See `config.example.yaml`. ### Changed - **siglab names the no-lock reason + opt-in soft-sync fallback** (#771). The scan verdict now classifies *why* a control channel did not lock — SNR/EVM-limited, frame-sync-not-aligned, not-the-control-channel, or no-signal — from the demod and frame-decode metrics already gathered, rendered after the "did NOT lock" line and serialized into the CSV/JSON/YAML summaries. A `replay -soft-sync` flag (off by default; the daemon is unchanged) widens the P25 FSW correlator so sync words carrying 5–8 symbol errors decode, but admits those looser hits only through the existing TSBK-CRC-corroborated marginal NID tier, so a looser sync extends reach into marginal-SNR captures without manufacturing a false lock. ### Fixed - **DMR 2-slot cadence detected by AMBE FEC, not the embedded LC** (#644). The interleaved DMR voice decoder picked its same-slot TDMA cadence solely by which candidate stride reassembled a CRC-valid embedded Link Control — a decode that frequently never validates on a real outbound carrier, so it fell back to the 264-dibit cadence. On a 288-cadence carrier that sliced every burst 24 dibits off, splicing the other timeslot's / the CACH's bits into each AMBE frame and rendering audio as structured noise that "sounds encrypted" (the reopened symptom of #644). Cadence is now scored by AMBE Golay(23,12) corrected-bit count when no candidate yields an authoritative LC: a correct slice decodes with ~0 corrected bits, a wrong slice scores far higher, and a ceiling-and- margin gate keeps genuinely garbled data from locking a cadence. The LC remains authoritative when it does decode. - **Silent-from-start calls end at a bounded window, not the 30 s watchdog** (#788). A voice grant whose channel never delivered a single matching voice frame (carrier already dropped, a stale CC grant re-announcement, or a mis-tuned / under-gained tap) was held by the boundary tracker for the engine's full call-timeout watchdog — 30 s by default — showing as an "active" call with a climbing ELAPSED and no audio. A tracker that has never seen a matching voice frame now ends the call (reason `timeout`) once it has run for 2× hangtime (7 s by default); if voice resumes, the CC re-announces the grant and a fresh call starts. ## [v0.5.1] — 2026-06-23 A maintenance release headlined by a new **ka9q-radio network SDR source** — mount a channel from a remote `radiod` instance over IP multicast as a virtual tuner, so one well-sited front end can feed many GopherTrunk decoders across a LAN — and a fix that restores **wideband decode at sample rates above 2.5 MS/s** (#764), where a `role: wideband` dongle run at 10 MS/s went deaf across every tap. The rest is P25 control-channel work: System ID recovered from adjacent-site broadcasts, RFSS/Site from the Location Registration Response, hex ID rendering, a corrected Phase 2 Motorola talker-alias reassembly, and new raw-broadcast / event-log field diagnostics. ### Added - **ka9q-radio network SDR source** (`sdr.ka9q_radio`, #765). Consume a channel from a remote ka9q-radio `radiod` instance over IP multicast, in pure Go. radiod runs fast-convolution downconverters on a front end and multicasts each channel as RTP; a channel in raw "linear" IQ mode (output_channels = 2) is mounted as a virtual tuner, so one well-sited radiod can feed many GopherTrunk decoders across a LAN. The driver discovers the channel's IQ multicast group, sample rate and encoding by polling radiod's status group, resolves `.local` instance names via mDNS, retunes via RADIO_FREQUENCY commands, and decodes s16/f32 (either byte order) IQ payloads. Configure under `sdr.ka9q_radio` with the status group `addr` and channel `ssrc`; see `config.example.yaml`. - **P25 raw status-broadcast dump + live event log** (#779). Two additive field diagnostics, no decode-behaviour change. A per-opcode sampled raw-payload + decoded-field dump for the handled adjacent (0x3C), network (0x3B) and RFSS (0x3A) status broadcasts captures the bytes behind a reported neighbour / identity mismatch when there is no IQ to replay. An optional live event JSONL/NDJSON sink (`log.event_log`) records every bus event as one JSON line in the same envelope the SSE/WS streams emit, with size-capped rotation; surfaced in the web Config Builder. ### Changed - **P25 identity recovered from more control-channel opcodes** (#766, #774). Systems that never transmit Network/RFSS Status Broadcasts (0x3B/0x3A) used to leave the web "Network identity" panel stuck on "Awaiting status broadcasts". The System ID is now voted in from Adjacent Site Status Broadcasts (0x3C), and RFSS/Site from the Location Registration Response (LOC_REG_RSP, 0x2B) — the practical identity source on such systems. WACN publishes the instant the Network Status Broadcast lands, and a new per-opcode seen-counter lets the hunt/siglab layers distinguish a genuinely unavailable WACN (NSB never transmitted) from a decode gap. - **P25 IDs render in hex** (#774). Site-update events and the systems/sites REST DTOs gain `*_hex` fields (decimal values unchanged); the web "Network identity" card and the TUI render WACN/System ID/RFSS/Site in the configured base (`web.id_base`, hex default). ### Fixed - **Wideband DDC/channelizer decode at sample rates above 2.5 MS/s** (#764). A `role: wideband` dongle run above 2.5 MS/s went deaf across every tap. The DDC path now runs a single shared decimate-by-D stage over the wideband stream to bring it into [2.5, 5) MS/s before the per-tap mixers, pinning per-tap cost to the proven ~2.5 MS/s regime so the pump goroutine keeps real time (below 5 MS/s the 2.4 MS/s path is unchanged). The channelizer bin count now scales with the sample rate to hold ~150 kHz bins (64 bins at 10 MS/s) instead of colliding adjacent carriers into one bin. - **P25 Phase 2 Motorola talker-alias reassembly** (#773). The Motorola FACCH-S alias data fragments begin on the low nibble of their sequence octet and must be concatenated as a nibble stream; the previous code dropped that leading nibble and concatenated whole bytes, shifting the cipher region by 4 bits so the alias decoded to garbage and failed safe to empty. Reassembly is now nibble-aligned (the end-to-end decoded string remains air-unverified). - **P25 Motorola talker-alias cipher gated as unverified** (#773). A clean-room attempt to derive the per-byte cipher from the one partial capture available (RID 200062) showed it is mathematically underdetermined — dozens of distinct constant-sets reproduce the known bytes while disagreeing on the unknown ones, and one alias character is unrecoverable from that sample — so the substitution table and constants cannot be pinned without ground truth (and SDRTrunk's GPLv3 implementation cannot be ported into Apache-2.0 GT). The decode now carries a `CipherVerified` flag (false) and `DecodeMessage` never reports an alias as reliable while it is false, so a possibly-wrong table can never surface a fabricated name as a confirmed alias. Misleading "reverse-engineered fact" comments on the table were corrected to state the true unverified provenance. - **P25 autotune no longer over-corrects** (#774). Implausible single AFC measurements are rejected, a warm-up of several samples is required before any correction is applied, and only cleanly-decoded voice calls feed the estimate, so convergence stays in the low-Hz range instead of drifting into the kHz. - **Web CC Activity table freezes synchronously on pause** (#772). The pause snapshot is now captured during render the first time pause is seen rather than in a post-paint effect, closing the window where an SSE batch could leak live rows through after the click. The web vitest suite is also wired into CI. ## [v0.5.0] — 2026-06-22 A feature release headlined by a **standalone P25 Phase 2 talker-alias signalling follower**: on Phase 2 systems the alias rides the traffic channel's FACCH-S MAC signalling during hangtime, so on a busy multi-site system — where most grants never get a voice tap and encrypted calls tear down before hangtime — it was almost never decoded. A new signalling-only follower allocates lightweight DDC taps on a `role: wideband` dongle and harvests the alias off the traffic channel independent of the voice pool, the way SDRTrunk does with two SDRs. Alongside it, a new **decode-activity-gated power log** gives the wideband engine an opt-in, per-channel IQ-power diagnostic that only records windows where a protocol is actually decoding — the "decoding but the signal is marginal" view — instead of spamming every idle or off-band channel. ### Added - **P25 Phase 2 talker-alias signalling follower** (`signalling_taps`, #376). On Phase 2 systems the talker alias rides the traffic channel's FACCH-S MAC signalling during hangtime, not the control channel — so decoding it used to require a voice tuner following the call. On busy multi-site systems most grants never get a voice tap (and encrypted calls are torn down before hangtime), so the alias was almost never decoded. A new signalling-only follower (`internal/sigfollow`) allocates lightweight DDC taps on a `role: wideband` dongle's IQ stream and harvests the alias off the traffic channel independent of the voice pool — the way SDRTrunk does with two SDRs. Enable it per wideband device with `signalling_taps: N` (0 disables; 2-4 suits a busy Phase 2 system). The FACCH-S MAC dispatch is now shared between the voice composer and the follower so the two paths never diverge. Decoded aliases bind onto the RID automatically via the affiliation tracker, with no extra wiring. - **Decode-activity-gated power log** (`log.power_log`). A new opt-in, rotating per-channel IQ-power log for the wideband engine. Each line is gated on decode activity across all four wideband protocols (DMR Tier II / Tier III, P25 Phase 1 / Phase 2), so idle and off-band channels never appear. By default it records only low-power (weak-signal) windows on channels that are actively decoding — the "decoding but the signal is marginal" diagnostic — with `log.power_log.all_windows: true` opting into a full power time-series of every decode-active window. To support the gate, DMR Tier III and P25 Phase 2 control channels now expose a `DecodedFrames()` counter alongside the existing Tier II / Phase 1 counters. ## [v0.4.9] — 2026-06-21 A field-driven bug-fix release built around six issues reported on a live P25 Phase 1 system, plus follow-up audio plumbing and import hardening. The headline fix closes a **cross-call audio-bleed window in the live PCM fan-out**: the earlier on-disk CallID fence didn't cover the streaming tap, so a reused wideband voice-tap serial could relabel a draining call's audio with the next call's talkgroup. The web **Scanner tab no longer crashes** on a null systems/channels payload, **trunked hunts now converge** on identity and topology instead of timing out in a 3 s dwell, and discovery stops inventing bogus talkgroups from unit-to-unit / telephone / SNDCP-data grants. On the side: RID and talkgroup **alias imports are hardened against mojibake** (UTF-16/BOM SDRTrunk exports are transcoded and non-printable runes stripped), `sdr list` **prints full HackRF serials** with a bounded `--probe`, and the gRPC API can finally **stream raw IMBE/AMBE+2 vocoder frames** to `include_raw` subscribers. ### Added - **Raw vocoder frame streaming over gRPC** (#746). The `AudioSubFilter.IncludeRaw` flag was a near no-op — `WriteRawFrame` was never wired into the publisher, so raw IMBE / AMBE+2 frames stayed recorder-only. A new raw tap mirrors the decoded-PCM path and fans the verbatim frame (plus vocoder name and CallID) to the audio publisher before decode, so it fires even for protocols with no in-process decoder (ProVoice, encrypted) where the raw bytes are the only audio. Raw is purely additive — PCM-only clients (including the WebUI) are unaffected, and the cross-call fence applies at both the publisher and recorder layers. - **Monitor-minutes field on the blind-sweep hunt form** (#746). Only the parse-control-channel form sent `monitor_seconds`; the blind-sweep payload omitted it, so a sweep could never engage the converge-and-stop monitor (backend default 0 = off). The sweep form now carries a "Monitor (minutes, 0 = off)" field, so a sweep that locks a control channel can monitor it until identity, neighbors, and band plan settle. ### Changed - **`sdr list` shows full HackRF serials and bounds `--probe`** (#745). The fixed-width SERIAL column front-truncated to 16 chars, which for a HackRF cut off the meaningful tail of its 32-hex part_id+serial and left only the all-zero prefix (reported as `0000000000000000`); TUNER and PRODUCT were clipped too. Columns now size to the widest value present, and each `--probe` open+info+close is wrapped in a bounded helper so a wedged device can't hang the command. Adds a Linux (udev) setup section to the HackRF doc. - **RID / talkgroup alias imports hardened against mojibake** (#744, issue #711). A shared sanitization layer is wired into both the RID and talkgroup CSV/JSON loaders: a UTF-16 (LE/BE) or UTF-8 BOM is honoured and transcoded to plain UTF-8 (neutralising Windows/SDRTrunk exports), and text fields are reduced to printable ASCII, dropping control chars, NULs, and non-ASCII mojibake. BOM-less ASCII/UTF-8 passes through untouched. Promotes `golang.org/x/text` to a direct require. - **Trunked hunts default to the converge-and-stop monitor** (#743). The web hunt previously ran a 3 s buffered dwell; P25 status broadcasts cycle too slowly to land in 3 s, so only NAC surfaced. A trunked hunt now defaults to the streaming monitor that ends early once identity + topology settle. ### Fixed - **Cross-call audio bleed when a voice-tap serial is reused** (#743). The on-disk CallID fence guarded only the WAV; the live PCM fan-out (recorder decodedTap → AudioPublisher) was keyed purely by device serial, so a reused wideband voice-tap serial leaked the previous call's still-draining audio to subscribers filtered on the new call's talkgroup. The session's CallID is now threaded through to `WritePCMForCall`, which drops a frame whose CallID no longer matches the serial's bound call. - **Web Scanner tab crash on a null payload** (#743, #746). A nil Go slice marshals to JSON `null`; the Scanner tab dereferenced `systems.length` / `channels.length` and crashed with "Cannot read properties of null". The `/api/v1/scanner` payload now emits `[]` and the frontend null-guards the reads, with frontend tests covering both the null-shaped and populated snapshots. - **Hunt identity/neighbors stuck on "awaiting status broadcasts"** (#743). Beyond defaulting to the streaming monitor, topology folding no longer drops a legitimate `RFSS=0` / `Site=0`. - **Bogus talkgroup from non-group grants** (#743). Unit-to-unit / telephone / SNDCP-data grants publish a 24-bit target unit as the grant "group", and discovery was recording every grant group as a talkgroup. These grants are now flagged Individual and skipped when building the hunt talkgroup list (the frequency is still recorded on the site). - **Spurious talkgroup↔frequency association** (#743). The per-talkgroup frequency list is dropped — on a trunked system the traffic channel is assigned dynamically per call, so a talkgroup has no fixed frequency. The site's distinct voice-channel pool is unchanged. ## [v0.4.8] — 2026-06-20 This release is dominated by a **six-phase operator-console overhaul** that rebuilds the web UI's navigation, feedback, primitives, tables, dense panels, and accessibility from the ground up. The flat tab bar is replaced by a registry-driven app shell — a collapsible desktop sidebar, a mobile bottom nav + drawer, and a ⌘K/Ctrl-K command palette — that reaches all 31 routes, including six DSP panels that were previously unreachable except by URL. On top of that: a toast notification system with a shared polling hook that kills re-render flicker and shows reconnect state, a reusable primitive layer with a new light theme and a comfortable/compact density axis, a DataTable with built-in search / pagination / inline actions / column visibility, regrouped Hunt / Scanner / Settings panels, and a pass over focus traps, a skip link, reduced-motion, and chart labelling (mirrored into the Signal Lab and Config Builder SPAs). On the decode side, **P25 TSBK coverage** gains named standard OSPs and a field-decoded Secondary Control Channel Broadcast — Explicit (0x29), empty/all-muted recordings are suppressed to stop tiny-file spam, and a per-call ID closes a cross-call audio-bleed window. The docs grow a new **Digital & Trunked Radio** learning path and a **Software Development** domain in the Reference encyclopedia. ### Added - **Web console navigation overhaul — app shell + grouped nav + command palette** (#736). A single nav registry feeds four surfaces — a collapsible desktop sidebar, a mobile bottom nav (4 primaries + More), a full mobile drawer, and a ⌘K/Ctrl-K command palette — organizing all 31 routes into six groups (Live Ops, Discovery, Signals & DSP, Decoders & Logs, Database, System). This rescues 21 panels that were stranded behind a hidden mobile overflow row and six DSP panels (constellation, symbols, eye, mixer, tuning, histogram) that were in no tab list at all. The daemon's `hidden_tabs` filter and the Config Builder external link are preserved. - **Notification + feedback system** (#739). A toast queue with kind-based auto-dismiss replaces the single manually-dismissed error strip; every existing `setError(...)` now surfaces a visible toast. A shared `useDataPoll` hook centralizes the poll/cancel pattern, only re-renders when a snapshot actually changed (killing poll-induced flicker), and shows a "reconnecting · Ns ago" chip when the daemon goes quiet. Mutations gain visible "saving…" / success states. - **Reusable UI primitive layer + light theme + density axis** (#739). A `components/ui/` set (Card, Badge, Input, Select, Checkbox, Field, PageHeader, EmptyState, Skeleton, Section, Button, Spinner, ToastViewport) lets panels stop hand-rolling controls. Design tokens gain a `[data-theme="light"]` theme (Settings now offers a dark/mono/light toggle) and a `[data-density]` axis (comfortable/compact), both applied pre-render and persisted. - **DataTable overhaul** (#739). The shared table gains opt-in, backward- compatible built-in search, pagination, loading skeletons, an inline row-actions cell, a toolbar slot, and a persisted column-visibility menu. Talkgroups, Radio IDs, Systems, Active, and History adopt these; Talkgroups also gets inline scan/lockout quick-toggles. - **"Digital & Trunked Radio" learning path** (#737). A new structured path under `/learn/` — 6 modules, 31 lessons + glossary — covering digital radio and trunking end to end: a brief history, the digital signal chain (vocoders, modulation, framing, control channels), each trunking system GopherTrunk decodes (P25 Phase 1/2 and DMR in depth; TETRA, NXDN, Motorola, EDACS, LTR, MPT-1327, dPMR, D-STAR, YSF more briefly), and hands-on decoding. Registered in `_data/learn.yml`, so the chooser, hub, lesson nav, and `llms.txt` pick it up automatically. - **"Software Development" domain in the Reference encyclopedia** (#738). The encyclopedia is restructured from a flat category list into a two-level Domain → Category → Entry model (the 11 existing RF categories move under an "RF & SDR" domain unchanged), and a full Software Development domain is built out: 61 new entries across 6 categories — 19 programming languages plus 42 concepts spanning language internals, concurrency, paradigms & design patterns, principles & quality, and testing/tooling/delivery — each cross-linked and wired into the learning paths. - **P25 Secondary Control Channel Broadcast — Explicit (0x29) decode** (#740). The explicit SCCB is now field-decoded and its downlink channel folded into the network topology (dispatched regardless of MFID like the other standard broadcasts), and the standard OSPs that were logging as `OSP(0xNN)`/unhandled — including 0x16 `SNDCP_DAT_CH_ANN_EXP` and 0x29 `SCCB_EXP` — are now named. ### Changed - **Dense panels regrouped onto the primitives** (#739). Hunt is rebuilt on the UI primitives (clearing long-standing styling debt where it referenced undefined CSS classes), split into a "Parse a control channel" quick-start, a collapsed "Blind sweep" advanced section, and result Cards. Scanner's three sub-panels move to Cards with state pills; Settings becomes a collapsible Section with a plain-language tooltip on each config field. - **Accessibility & cross-SPA polish** (#739). DetailModal and ConfirmModal now trap focus and restore it to the trigger on close; a visually-hidden "Skip to content" link jumps past the nav chrome; a `prefers-reduced-motion` rule neutralizes transitions (mirrored into the Config Builder and Signal Lab SPAs); and the Metrics trend chart gains `role="img"` + an aria-label. ### Fixed - **P25 opcode 0x39 was mislabelled** (#740). 0x39 is the non-explicit SCCB; the explicit variant is 0x29 (per TIA / SDRTrunk / OP25). The two are now named correctly. - **Empty/all-muted recordings no longer spam tiny files** (#740). A vocoder call that decodes no real speech (voiced + unvoiced == 0 — all idle-muted) now has its WAV/raw removed and publishes no `CallComplete`, instead of leaving a silent file that, in per-transmission mode, was the dominant source of tiny files. A per-call b₀ range + escalated WARN line lets a genuine dead-key be told apart from empty frames reaching the vocoder. - **Cross-call audio bleed when a voice-tap serial is reused** (#740). Each bound call now gets a process-unique `Grant.CallID` (preserved across Retune handoffs) threaded to the recorder, which drops frames whose CallID doesn't match the open session — closing the window where a reused tap could bleed one call's audio into another. ## [v0.4.7] — 2026-06-19 This release sharpens **P25 discovery accuracy** and **identity corroboration**. Status-broadcast identity (WACN / System ID / RFSS / Site) is now resolved by majority vote instead of last-write-wins, so a single corrupt-but-CRC-passing TSBK can no longer poison the system map; the always-standard band-plan / network / site opcodes are decoded even when a Motorola trunk sends them under the vendor MFID (#728); registration and affiliation events carry `rfss_id` / `site_id` / `nac` for a genuine RID→site fix (#698); and a new opt-in streaming long-dwell control-channel monitor watches a site for minutes without recording gigabytes of IQ (#722). Encrypted-call handling moves to a per-system policy and `metadata` mode now releases the voice tuner the moment the talker alias completes (#711). New **autotune** tracks each dongle's carrier error and digitally corrects it (#729), the web console gains a group-duplicate-events toggle, a freeze-while-inspecting stream, and a fixed CC Activity pause, and a RadioReference fix recognises feed-provider / admin accounts as premium (#723). On the docs side: a new "Intro to Software Dev" learning path (#725). ### Added - **Group duplicate events in the Events & CC Activity panels.** A "Group duplicates" toggle (on by default) collapses repeated events with identical content — the same grant / registration / affiliation re-sent every few seconds — into one row with an ×N count and the latest timestamp, so the panels stop spamming. Toggle off for the raw per-event stream. - **Motorola opcodes 0x05 / 0x09 (MFID 0x90) named and captured.** Cross-checked against SDRtrunk / OP25: 0x05 is `MOTOROLA_OSP_TRAFFIC_CHANNEL_ID`, 0x09 is `MOTOROLA_OSP_SYSTEM_LOADING` — neither carries neighbour / secondary-CC data, and neither reference decoder field-decodes them. So they are named and their raw payload is logged at INFO (up to 64 samples each, grep `motorola opcode`) while decoding/publishing nothing into the system map. See [`docs/specs/p25-motorola-opcodes.md`](docs/specs/p25-motorola-opcodes.md). - **Autotune — per-dongle carrier-error correction** (opt-in, `sdr.autotune`). Ported in concept from trunk-recorder's autotune. Each SDR's crystal error shifts the received carrier off-centre; with autotune on, the daemon watches the locked P25 Phase 1 receiver's residual carrier offset (control channel and voice calls), keeps a running average of the last 20 measurements per device serial, and shifts the channel's digital down-converter by that average so the demod's AFC starts near lock at the next acquisition. It never rewrites the dongle's hardware ppm — it logs a suggested value (with a >3.5 PPM "verify your ppm" warning) you can bake into the device block by hand. Off by default and zero-cost when disabled; safe to leave on for TCXO-equipped units (the correction stays near zero). - **Site identity on registration & affiliation events** (#698). The `unit_registration` and `affiliation` events now carry `rfss_id` / `site_id` (alongside `grant`), plus a `nac` field on all three. Registration and affiliation are handled by the radio's actual serving site, so they give a genuine RID→site location fix that grant-site — announced on every site of a wide-area call — cannot. Phase 2 (TDMA) control channels now also discover their sites into `GET /api/v1/sites`. - **Streaming long-dwell control-channel monitor for Hunt** (opt-in). A new `monitor_seconds` (web Hunt "Monitor" field / `-monitor-seconds` CLI flag) decodes a locked control channel from the live SDR in real time instead of buffering the whole dwell, so a P25 site can be watched for minutes without recording gigabytes of IQ. It stops early once identity, neighbors, and the band plan stop changing (converge-and-stop), capped at the requested time. - **P25 Unit-to-Unit Answer Request decoding** (opcode 0x05). The private-call answer-request handshake is now decoded — in both the standard and Motorola (MFID 0x90) forms — and published as a new `unit.request` bus event carrying the calling/called radio IDs, surfaced on the web CC-activity panel and the message log instead of being logged as an unhandled vendor TSBK. - **"Intro to Software Dev" learning path** (#725). A third structured path under `/learn/` — 7 modules / 40 lessons plus a glossary — covering software development from a brief history through languages, principles, design patterns, development systems, choosing a language, and building a solo developer stack, with examples carrying a subtle RF/SDR slant. Joins the existing RF & SDR and Git & GitHub paths in the shared data-driven Jekyll system, so the chooser, hub, lesson nav, and `llms.txt` pick it up automatically. ### Changed - **P25 TSBK opcodes now log with their TIA-102.AABC-D designations** (`UU_ANS_REQ`, `NET_STS_BCST`, `GRP_V_CH_GRANT`, …) instead of the OP25-derived names. The Go identifiers stay descriptive; `Opcode.String()` and a new [`docs/specs/p25-tsbk-opcodes.md`](docs/specs/p25-tsbk-opcodes.md) carry the canonical mnemonics (mirrors the NXDN package convention). - **Encrypted-call handling is now per-system** (#711). The `encrypted_calls` policy (`mode` + `metadata_follow_ms`) moved from the global `trunking.encrypted_calls` key to per-system `trunking.systems[].encrypted_calls`, so an operator can run `metadata` on one system and `follow`/`ignore` on another. **Breaking:** a global `trunking.encrypted_calls` block from v0.4.6 no longer takes effect — move it under the relevant `trunking.systems[]` entry. Because it is now per-system config, the setting is applied at startup (restart-required); the runtime API / TUI / settings-PATCH fields for it have been removed. - **`metadata` mode releases the voice tuner as soon as the talker alias completes** (#711, building on #376), instead of always holding for the full `metadata_follow_ms`. On P25 Phase 2 the alias arrives during call hangtime as a FACCH-S block sequence; once it fully reassembles the metadata goal is met, so the tuner is freed immediately — `metadata_follow_ms` becomes an upper bound rather than a fixed wall-clock hold. ### Fixed - **UU_ANS_REQ (opcode 0x05) is no longer mis-decoded under a vendor MFID.** A Motorola (MFID 0x90) 0x05 decoded with the standard Target/Source layout produced garbage radio IDs (`src=0`, `target=0x3C0000`); it is now left unhandled (vendor namespace), while the standard-MFID UU_ANS_REQ still publishes a `unit.request` event. - **CC Activity "pause" now actually freezes the table.** Previously it re-sliced the live rows, so the list kept churning and an event was impossible to read or click; it now snapshots the rows on pause. - **Events stream freezes while inspecting an event.** Opening an event for inspection no longer lets the live stream churn underneath the selection, so the row you clicked stays put until you dismiss it. - **Systems detail no longer mislabels missing network identity as "Scanner offline".** WACN/System ID/RFSS/Site are decoded live from status broadcasts whenever the control channel is received, so an empty cell now reads "Awaiting status broadcasts" (or "Hunting control channel" during a hunt). - **Systems detail modal no longer crashes on a null systems list** (#721). Clicking a system crashed with "Cannot read properties of null (reading 'find')" whenever the scanner reported no active control-channel hunt statuses (the daemon marshals an empty hunt list as JSON `null`); the optional chain now guards the systems list too (`scanner?.systems?.find`). - **Hunt progress frequency precision.** The identifying-phase progress line rounded the tuned frequency to 3 decimals (showing a 6.25 kHz-raster channel like 160.5875 MHz as "160.588"); it now prints 4 decimals, matching the rest of the UI. Display-only — the SDR was always tuned to the exact frequency. - **P25 identity accuracy under noise.** Status-broadcast accumulation was last-write-wins, so a single corrupt-but-CRC-passing TSBK could set a wrong WACN / System ID. Identity (WACN/SysID/RFSS/Site) is now resolved by majority vote across observations, so a repeated correct value out-votes a one-shot wrong one. Neighbors and band-plan slots surface on the first sighting (de-duplicated) to match OP25's latency. - **P25 Motorola identity/neighbors not decoding.** On some Motorola trunks the band-plan and network/site/secondary broadcast opcodes (IDEN, RFSS, Network Status, Adjacent Site) arrive under the vendor MFID (0x90); they were routed to vendor dispatch and dropped, so WACN / System ID / neighbors stayed empty even though the control channel locked. These always-standard opcodes are now decoded regardless of MFID (an INFO line flags when one is seen under a vendor MFID), matching OP25/boatbod. - **DMR Tier III Announce Channel-Frequency** (C_BCAST anncd_type 5) is now recognised and its raw payload surfaced once at INFO, so its (site-specific) layout can be validated off-air; neighbor/voice frequencies continue to resolve through the configured band-plan resolver / LCN learner. - **RadioReference feed-provider / admin accounts now recognised as premium** (#723). `getUserData` returns the sentinel strings `"Never - Feed Provider"` / `"Never - Admin"` as the subscription expiry for accounts that hold premium for as long as their feed is active; the parser only handled dates / UNIX timestamps, so these fell through to "no active premium subscription". A `"Never…"` expiry is now treated as active. ## [v0.4.6] — 2026-06-18 This release focuses on **P25 site and identity decoding**. Encrypted calls are now configurable — a new `trunking.encrypted_calls.mode` stops a few long encrypted calls from exhausting the voice-tuner pool and starving clear traffic (#711). Every `grant` event carries the decoded RFSS/site, exposed alongside a new `GET /api/v1/sites` endpoint (#698), the web Systems panel's WACN / System ID / RFSS / Site fields finally populate from the live site tracker instead of sitting on "Awaiting status broadcasts" (#673), and a Phase 1 status-broadcast parsing bug that had every WACN / System ID / RFSS / Site / neighbor reading one byte early is fixed (#716). Motorola **talker aliases** now decode off the Phase 1 TDULC terminator and Phase 2 FACCH-S, not just LDU1 (#376), and the Hunt discovery view surfaces the voice/traffic frequencies behind each grant. On the docs side: two new deep-dive blog series ("Build in the Open" and "RF Front End") and a full Git & GitHub learning path. ### Added - **Configurable handling of encrypted calls** (#711). A new `trunking.encrypted_calls.mode` selects how the engine spends scarce voice SDRs on encrypted calls: `follow` (default, unchanged — hold the tuner for the whole call), `metadata` (follow briefly to capture traffic-channel metadata — talker alias, source RID, encryption sync — then release the tuner after `metadata_follow_ms`, default 1500 ms), or `ignore` (never tie up a voice SDR on an encrypted call). Encryption is acted on both at grant time and when discovered mid-call. A call whose KeyID matches a configured `trunking.systems[].encryption_keys` entry is exempt and always followed. The mode is runtime-mutable via `PATCH /api/v1/settings` (`trunking_encrypted_mode` / `trunking_encrypted_metadata_follow_ms`), the TUI Settings panel, and SIGHUP reload — no daemon restart required. This stops a few long encrypted calls from exhausting the tuner pool and dropping clear traffic with `no voice device available for grant`. - **Voice-channel frequencies in the Hunt discovery view.** The discovery model now records the band-plan-resolved voice/traffic frequency of every grant, surfaced both per-talkgroup (a Frequencies column) and per-site (a Voice channels column) in the Hunt panel, and in the `system` JSON as `talkgroups[].frequencies` and `sites[].voice_channels`. - **P25 site identity in grant events and a `/api/v1/sites` endpoint** (#698). Every `grant` event now carries `rfss_id` / `site_id`, decoded from the camped site's RFSS Status Broadcast, so downstream tooling (Prometheus exporters, dashboards) can label calls by site instead of an opaque `channel_id`. A new `GET /api/v1/sites` endpoint lists the sites GT has discovered from the control channel — each with the control-channel frequency it was heard on — merged with optional human-readable names configured per system under `trunking.systems[].sites` (`rfss` / `site` / `name`). - **P25 Motorola talker aliases on TDULC and FACCH-S** (#376). Real Motorola talker aliases ride two carriers the decoder previously skipped past — the Phase 1 TDULC terminator and the Phase 2 FACCH-S — not just LDU1 link control, so aliases never surfaced on many systems. All three carriers now share one decode primitive (a new `internal/radio/p25/motorola` package handling the cipher + WACN|System|RID|alias|CRC-16 framing), and completed aliases publish the existing `KindTalkerAlias` event, bound onto the RID and surfaced at `/api/v1/rids`. - **P25 System Information panel populates from the live site tracker** (#673). The web Systems detail panel showed WACN / System ID / RFSS / Site stuck on "Awaiting status broadcasts" because `GET /api/v1/systems` built its DTOs solely from static config. The endpoints now overlay the always-on site tracker's decoded over-the-air identity (WACN/System ID system-wide, RFSS/Site from the most recently heard site), falling back to config values when nothing has been decoded yet. - **"Build in the Open" blog series.** A 14-part tutorial series taking a project from idea to public release with GitHub and Claude Code, using GopherTrunk as the worked example, drip-released one post per weekday. - **"RF Front End" blog series.** A 14-part deep dive into GopherTrunk's pure-Go RF source layer (RTL-SDR, Airspy, HackRF): the Device contract, the driver registry, the no-libusb USB transport across Linux/macOS/Windows, per-radio register bring-up, and IQ conversion. - **Git & GitHub learning path.** The single "Learn RF & SDR" curriculum becomes a multi-path "Learn" section, adding a complete 29-lesson Git & GitHub path alongside the existing RF & SDR one. Old RF lesson URLs keep working via redirects. ### Fixed - **P25 discovery now decodes WACN / System ID / RFSS / Site / neighbors correctly** (#716). The Phase 1 Network-, RFSS-, and Adjacent-Site Status Broadcast TSBK parsers were dropping the leading LRA byte, so every field read one byte early — WACN and System ID came out wrong, RFSS/Site read as 0, and advertised neighbors showed garbled IDs with no resolvable frequency. The parsers now follow the TIA-102.AABF layout (matching the repo's independent Phase 2 decoders), which also corrects the `rfss_id` / `site_id` labels on `grant` events and `KindSiteUpdate`. ## [v0.4.5] — 2026-06-17 This release pairs two operator-facing fixes with a large docs-site expansion. The Hunt panel's control-channel parser, previously locked to P25, now covers **every trunked protocol with a standalone control channel** — P25, DMR Tier III, NXDN, dPMR, EDACS, Motorola, MPT 1327, TETRA, and YSF — via a protocol selector, and its listen window becomes a free-form seconds field instead of a fixed dropdown (#707). On the decode side, a fast scanner retune could race the USB stream teardown and fail the next capture with `stream already active` / `conv: StreamIQ failed`; the re-open now waits on the in-flight teardown across all drivers (#686). And call history finally keeps the **mid-call backfilled radio ID and encryption** on P25 Phase 2 grants, which start with a placeholder RID and only surface identity on the traffic channel (#696). Alongside the code, the docs site gains the **GopherTrunk Reference** encyclopedia (≈180 cross-linked entries with hand-authored SVG diagrams) and a 14-part **SDR Internals** deep-dive blog series. ### Added - **GopherTrunk Reference encyclopedia (#700, #701).** A new data-driven `/reference/` encyclopedia (≈180 entries) spanning RF fundamentals, modulation, voice coding, SDR & DSP, algorithms, antennas & propagation, hardware, trunked radio, protocols, people, and organizations. Entries follow a uniform profile, most carry a hand-authored theme-aware inline SVG structure/concept diagram, and the set is wired into the glossary and `llms.txt` so it is discoverable to both readers and AI search. - **"SDR Internals" blog series (#705).** A 14-part deep-dive series — from "what is software-defined radio" through the driver registry, the streaming pool & concurrency, DSP foundations, tuning/channelization, demodulation, symbol timing, equalization/FFT, framing/FEC, and the protocol-decoder state machines — with a series landing page and nav entry. - **Download / support / community CTAs across the docs site (#697, #702).** Non-home pages and the reference layouts now carry consistent call-to-action links, with tightened per-page meta descriptions. ### Changed - **Hunt control-channel parser now covers all trunked protocols (#707).** The "Parse a control channel" Hunt panel was hardcoded to `p25` over an already protocol-agnostic API; it now exposes a protocol selector for every trunked protocol with a dedicated standalone control channel (P25, DMR Tier III, NXDN, dPMR, EDACS, Motorola, MPT 1327, TETRA, YSF) and forwards the choice to the hunt start. The fixed 5/10/15/20 s "Listen for" dropdown becomes a free-form numeric seconds input (min 1, default 15) with a hint about the ~19 MB/s IQ cost. No backend change. ### Fixed - **Scanner retune no longer races the IQ stream teardown (#686).** In scanner mode a fast retune cancels the IQ stream's context and immediately re-opens it, but the USB drivers tear a stream down in a detached goroutine that clears `streaming` only after `StopBulkIn` and the receiver-off control transfer complete — so the next `StreamIQ` often saw `streaming == true` and failed with "stream already active" (logged repeatedly as "conv: StreamIQ failed"). The fail-fast guard is replaced with a context-bounded wait on a per-stream `streamDone` channel, applied identically to rtlsdr, airspy, hackrf, and airspyhf, with a per-driver regression test that cancels and re-opens without draining the first stream. - **Call history keeps the mid-call RID + encryption on call end (#696).** `recordEnd` previously wrote only `ended_at` / `duration` / `reason`, discarding the call's final identity. On P25 Phase 2 compressed grants the source RID starts at 0 and is backfilled mid-call on the traffic channel, and `ALGID`/`KID` likewise surface mid-call (Phase 1 LDU2, Phase 2 EncryptionSync) — so `call_log` rows kept the grant-time placeholders (`source_id=0`, `algorithm_id=0`) and RIDs never appeared in history on exactly those Phase 2 systems. The end UPDATE now also persists `source_id` / `encrypted` / `algorithm_id` / `key_id` from the end grant with never-downgrade semantics (`COALESCE(NULLIF(?, 0), col)`, and an encryption flag that only upgrades). (`recordings.skip_encrypted` is unrelated — it only gates WAV/raw file writing.) ## [v0.4.4] — 2026-06-16 This release is the **RF & SDR learning hub** — a structured, vendor-neutral learning path at [`/learn/`](https://gophertrunk.org/learn/) that walks a reader from complete SDR newbie to confident GopherTrunk operator across six modules (30 lessons plus a glossary). Every lesson carries a TL;DR, answer-first question-style sections, reference tables, a hand-authored theme-aware inline SVG where a diagram helps, a self-check quiz, an FAQ, prerequisite chips, and "use this in GopherTrunk" links — paired with a dependency-free **site-wide search** over every page, lesson, and post (#693). On the operator side, the Hunt web panel gains a **"Parse a P25 control channel"** section that decodes a single CC frequency and renders the discovered system in radio-programming format — WACN / System ID / NAC, the voice-channel band plan, and talkgroups (dec/hex, encrypted flag, activity count) (#694). P25 Phase 1 now **decodes Motorola patch-group (super-group) voice channel grant / grant-update** TSBKs that previously logged as unhandled, so those super-group calls surface their radio ID and encryption and route through the normal band-plan / Phase 2 TDMA path (#376). And a live TETRA-zone survey that **tore down the WebSocket stream on a non-finite float** (a `+Inf` SNR / confidence reaching the JSON encoder, which cannot represent it) is fixed end-to-end: clamped at the hunt classify chokepoint, scrubbed at the JSON wire boundary for every event kind, and bounded to a finite SNR ceiling at the P25 estimator source (#648). ### Added - **RF & SDR learning hub at [`/learn/`](https://gophertrunk.org/learn/) (#693).** A data-driven curriculum (`_data/learn.yml`) powers a hub landing page, a glossary, and 30 lessons across six modules — RF fundamentals (bands, antennas, propagation), signal anatomy & modulation, the SDR receiver chain (IQ data, sample rate & Nyquist, gain & AGC, hardware), DSP (FFT/waterfall, filtering & decimation, demodulation, clock recovery), digital voice (analog vs digital, vocoders, the protocol landscape, encryption, other signals), and operating (finding systems, tuning with scopes, calibration & troubleshooting, legal & ethical). Each lesson has a TL;DR key-takeaways block, answer-first sections, reference tables, a hand-authored theme-aware inline SVG diagram, a knowledge-check quiz, an FAQ (mirrored as `FAQPage` schema), prerequisite chips, and "use this in GopherTrunk" links; progressive-enhancement JS adds a `localStorage` progress tracker, frequency↔wavelength and dB/dBm calculators, and the quizzes (pages stay fully usable with JS off). Per-lesson JSON-LD (`TechArticle` / `BreadcrumbList` / `FAQPage`) and a `Course` / `ItemList` on the hub make it SEO- and AI-search-friendly. - **Site-wide client-side search (#693).** A dependency-free `/search/` over the whole docs site (top-level pages, the 30 learn lessons, and blog posts), suitable for static GitHub Pages with no external service or API keys: a Jekyll-built `search.json` index, a compact nav search box (full-width in the mobile overlay), live as-you-type ranked results with match highlighting, and `?q=` deep-links. Progressive — the form submits `?q=` to `/search/` with JS off. - **P25 control-channel parser in the Hunt web panel (#694).** A "Parse a P25 control channel" section in the Hunt tab: enter a single CC frequency (MHz), pick a bounded listen duration (5–20 s), and the existing hunt decode path decodes it. The discovered system — already served on `GET /api/v1/hunt` — now renders in a human-readable radio-programming format: system identity (protocol, WACN, System ID, NAC in uppercase hex), the per-channel band plan (base / spacing / bandwidth / TX offset) needed to program Motorola P25 radios, and talkgroups (dec/hex, encrypted flag, activity count). No backend changes. - **P25 Phase 1 decodes Motorola patch-group channel grant / update (#376).** Field captures from a Motorola system (MMR, sites CBD/Mt Anakie) emit manufacturer-specific (MFID `0x90`) TSBKs at opcodes `0x02` and `0x03` that GopherTrunk logged as unhandled — the raw-payload diagnostic confirmed they are patch-group (super-group) **voice channel grant** and **grant-update** messages, not talker aliases. They now decode through the same band-plan / Phase 2 TDMA routing as the standard grant/update opcodes. The `0x02` form carries the source RID and the service-options encryption bit, so the patch-group calls that previously arrived `src=0` / `enc=false` now surface their radio ID and encryption, and GopherTrunk follows the super-group voice calls it was dropping. (The unidentified `0x90` opcode `0x16` is left in the unhandled-TSBK census pending ground truth.) ### Changed - **P25 Phase 1 unhandled-TSBK diagnostic now dumps the raw payload (#376).** The first round of CBD/Mt Anakie field diagnostics confirmed the alias is *not* arriving as our control-channel working-model fragment (vendor opcode `0x15`, plain ASCII): zero `cc talker alias`/`fragment` lines fired on either system, even the cleanly-decoding one. The census instead named candidate opcodes (Motorola `0x90` opcode `0x16`; standard `0x15`/`0x16`/`0x30`) but we couldn't tell which carries the alias from the opcode number alone. The `p25: unhandled tsbk` census line now also prints the **numeric** opcode (`Opcode.String()` mislabels vendor opcodes with standard names), and a new `p25: unhandled tsbk payload` line logs the raw 8-byte payload hex, capped at 8 samples per distinct `(MFID, opcode)` so a multi-block alias sequence can be captured and reversed against known RIDs / alias strings without flooding a busy control channel. The prior diagnostic round shipped only on an unmerged branch, so a field replay built against `main` saw no payload lines; this lands it on `main`. ### Fixed - **Live survey / WebSocket stream survives non-finite floats (#648).** A live TETRA-zone survey could not finish: a marginal carrier produced a non-finite float (a `+Inf` SNR / siglab confidence, or `-Inf` analog power from a divide-by-zero / log-of-zero), `json.Marshal` rejected it (`unsupported value: +Inf`), and the WebSocket handler tore the connection down on the write error so the scan never completed. Fixed at three layers: `internal/hunt` clamps every non-finite float on `DetectedSignal` (top-level plus nested Features / Trunking / Analog measurements) to `0` at the `classifyAndRoute` chokepoint every real signal flows through (reaching the stored survey, the event bus, and the NDJSON sink); a centralized, reflection-based `scrubNonFinite` returns a JSON-safe copy at the two serialization chokepoints (`eventToDTO`, covering both WS and SSE for every event kind, and the siglab identify + wideband REST responses), copying as it descends so the canonical store and NDJSON sink keep their original values; and the WS handler now marshals-first and skips-on-error (mirroring SSE) instead of killing the client's stream on one bad frame. Backstopping the source, the four P25 SNR estimators now return a finite `99.0 dB` ceiling instead of `+Inf` for a noise-free input, with a digital-vs-analog SNR ground-truth harness added as the regression guard. ## [v0.4.3] — 2026-06-15 This release is **Signal Lab wide-band tooling**, paired with a TETRA real-air fix and two decode-validation milestones. Offline auto-tune used to chase only the single loudest carrier, so a control channel that was both **off-centre and not dominant** in a wide capture was missed; siglab and Hunt now rank every carrier in the band and prefer the one that actually *locks*, fixing the live P25 captures that previously mis-latched (#678). Building on that, a new **wide-band multi-carrier survey** takes one IQ grab, finds every carrier, decodes each, and recognises a DMR Tier III trunked system in a single shot — exposed as a siglab library call, an offline `hunt -wideband` path, a `POST /api/v1/siglab/wideband` endpoint, and a matplotlib-style *Wideband* panel in the web console (#677, #679). A new **`gophertrunk spectrum`** subcommand prints the band RMS power, Welch-averaged spectrum, and detected carriers as text / JSON / CSV — the Python-free analog of `numpy.fft` + `matplotlib` (#680). Signal Lab's Results-view PSD and spectrogram now compute **server-side in Go**, letting the heavy **TensorFlow.js** browser dependency be dropped entirely (#680). On the decode side, TETRA control channels now **recover the BSCH colour code under any π/4-DQPSK rotation** so real-air downlinks (which sit at a non-zero rotation) actually learn their colour code (#681, #648), with a new differential-phase *rotation tracker* viz to make the rotation visible (#682). Finally, live captures **confirm DMR BPTC/RS Full-LC and P25 Phase 1 C4FM control-channel decode on air**, closing two long-standing real-air validation gaps (#675, #676, #527). ### Added - **`gophertrunk spectrum` subcommand.** A Python-free, no-server analog of `numpy.fft` + `matplotlib`: given a recorded capture it prints the band RMS power, the Welch-averaged spectrum, and the detected carriers as text, JSON, or CSV — absolute frequencies with `-freq`, otherwise offsets from DC. On the delivered P25 captures it lists the 449.875 MHz control channel at SNR ≈47 dB. (#680) - **Signal Lab wide-band multi-carrier survey.** `siglab.SurveyWideband` / `SurveyWidebandIQ` take a wide-band IQ buffer, find every carrier (Welch spectrum → peak detection → power-weighted centring of wide C4FM humps → grid-snap), down-convert and identify each, then cluster the results into control-vs-voice system rollups — recognising a DMR Tier III trunked system in one shot rather than auto-tuning to a single dominant carrier. Reused by a new offline `hunt -wideband -in ` path that folds every DMR carrier into one discovered system. (#677) - **Wide-band survey over REST + web.** `POST /api/v1/siglab/wideband` surveys a staged capture and returns the averaged band spectrum, per-carrier results, and system rollups (spectrum collection gated so the offline path stays lean). A new *Wideband* panel in `web/siglab` renders the averaged spectrum with each detected carrier marked by control/voice role alongside the trunked-system rollup table. (#679) - **Hunt / siglab auto-identify of off-centre, non-dominant control channels.** Offline auto-tune now detects and ranks multiple carrier candidates (`dsp.EstimateCarrierCandidatesHz`) and, under `-auto-tune`, tries each — preferring a carrier whose decoder actually *locks* over a louder one that merely looks plausible — so a control channel under a louder neighbour is found automatically (13/13 live P25 segments now lock, was 11/13). Hunt gains a `-detect-carriers` flag that FFT-averages a recorded buffer and sweeps every detected carrier through the classify/decode body, inventorying a whole band from one capture with no SDR. (#678) - **π/4-DQPSK rotation tracker viz.** Signal Lab's Results view gains a `RotationTracker` plot of per-symbol differential phase against the ideal ±π/4 / ±3π/4 rails (populated on the CQPSK path), making constellation rotation directly visible. Backed by new native-Go DSP helper packages (`internal/dsp/stats`, `internal/dsp/phase`) consolidated from previously inlined numpy/scipy-style operations. (#682) ### Changed - **Signal Lab computes the Results-view PSD and spectrogram server-side; drops TensorFlow.js.** New `GET /api/v1/siglab/jobs/{id}/psd` and `/spectrogram` endpoints compute the spectra in Go from the job's captured IQ (`spectrum.AverageDB` / `Spectrogram`); the PSD, Spectrogram, and Compare overlay views now fetch the server-computed spectrum by job id instead of computing it in-browser. The `@tensorflow/tfjs` dependency — used only for that in-browser compute — is removed from `web/siglab`, shrinking the bundle. Plotly, Chart.js, and D3 are unaffected. (#680) - **Wide-band survey DSP primitives promoted into shared, tested functions.** The Welch-averaged spectrum (`spectrum.AverageDB`), boxcar smoothing, robust percentile noise floor, power-weighted carrier centroid, RMS / dB helpers, and an STFT spectrogram now live in `internal/dsp/spectrum` and `internal/carriers` instead of being inlined and duplicated across siglab and hunt. Behaviour-preserving refactor guarded by the existing survey tests. (#679, #680, #682) ### Fixed - **TETRA BSCH colour-code recovery was rotation-blind** (#648). π/4-DQPSK carries data in the differential phase, so a residual carrier offset cyclically rotates the whole demodulated stream by an unknown 0–3; the synchronisation-training-sequence correlator that gates BSCH colour-code recovery matched only the rotation-0 orientation, so real-air downlinks (which sat at rotation 1) never learned a colour code and the reference carrier never locked — while the synthesised fixtures, all at rotation 0, hid the bug. `RecoverColourCode` and the live STS detector now correlate the sync training sequence under all four rotations. (#681) ### Validated - **DMR BPTC/RS Full-LC decode confirmed on real air** (#527 follow-up). Live 441 MHz / 2 MS/s DMR Tier III captures replayed through the production receiver decode the `BPTC(196,96) → RS(12,9) → Full LC` chain cleanly (Terminator-with-LC recovers a stable RS-validated FLC; control-channel CSBKs pass BPTC + CRC and the Tier III control channel locks), refuting the "real-air BPTC/RS is broken" hypothesis. Captured as no-build-tag regression tests with committed channelised fixtures. (#675) - **P25 Phase 1 C4FM control-channel demod validated on a live UHF capture** (#676). Thirteen live 450.500 MHz / 2 MSPS captures of a real UHF P25 system all decode to NAC `0x2C1`, with C4FM consistently out-decoding CQPSK; the cleanest segment reads EVM 12.7 %, SNR ≈14.5 dB, NID trusted 31 / failed 0, TSBK 36 with zero CRC/trellis failures. Recorded as a demod-quality gate (`samples/p25/p25-450875-cc.metadata.json`) with floors below the measured values. (#676) ## [v0.4.2] — 2026-06-14 This release is mostly **hunt / survey maturation**, with TETRA blind identify and MPT 1327 lock hardening alongside. The live discovery flow grows up: a real **RadioReference cross-reference** now pairs discovered control / voice frequencies against an existing RR system to flag PPM / tuning offsets and not-in-RR frequencies, and lists talkgroups heard on air but absent from RR — available both from the CLI and the web *Hunt* panel (#660, #661). Surveys gain **crash-safe NDJSON persistence and resume** in the daemon (mirroring the CLI flags), an optional **post-run auto-gain BER sweep** that recommends the front-end gain minimising decode errors on each locked control channel, and **neighbour-frequency resolution for DMR Tier III / EDACS / Motorola** rather than P25 only (#660, #661). The sweep now **snaps candidates to the channel raster** (6.25 / 12.5 / 25 kHz) and normalises the reported occupied bandwidth, so a real carrier a few hundred Hz off a bin centre is tuned dead-on and reads its true channel width instead of a skewed "11.6 kHz" (#669, #648). TETRA control channels now clear the blind-identify gate by **recovering the cell colour code from the BSCH** so their SCH/HD FEC actually scores (#662, #648), with an opt-in **Viterbi correction-depth histogram** behind `metrics.detailed_fec` (#667). MPT 1327 stops false-locking on stray cross-protocol parses — a lock now needs a minimum number of confirmation codewords sharing a **consistent system prefix** (#669, #670, #648). On the voice side, digital chains are now clocked from the **DDC's actual resampled rate** instead of the nominal 48 kHz, killing the periodic symbol-clock slips and audible clicks on wideband P25 / DMR voice (#668, #550). SoapyRemote endpoints gain per-endpoint **`stream_mtu` and `stream_window`** knobs (#664, #665), and P25 Phase 1 control-channel talker-alias decode is now observable in the daemon log (#376). ### Added - **Hunt: RadioReference cross-reference (offsets + new talkgroups).** The RR step is upgraded from duplicate-detection to a real cross-reference: when a discovered system matches an existing RR system with high confidence, GopherTrunk fetches its full detail and diffs it — control / secondary frequencies are paired greedily by nearest RR frequency (within 5 kHz is flagged as a tuning / PPM offset, farther or unmatched is "not in RR"), and talkgroups observed on air but absent from RR are listed. The diff is rendered in the submission package and, via a new `GET /api/v1/hunt[/{id}]/radioreference` endpoint, in a *RadioReference* section of the web *Hunt* panel (county / SID inputs + a cross-reference button). (#660, #661) - **Hunt: optional post-run auto-gain BER sweep.** A new `-auto-gain` flag (and an *Auto-gain* toggle + recommendations table in the *Hunt* panel) sweeps a set of front-end gains on each locked control channel after a run, decodes a short dwell at each, and recommends the gain that minimises the decode error rate (preferring one that locks, then lower gain to reduce front-end strain). Recommendations are reported and written to `gain-recommendation.json`; nothing is applied automatically. The web path is guarded by SDR exclusivity — gain control is wired only when the hunt holds a dedicated SDR, not the shared control SDR. (#660, #661) - **Hunt: daemon survey persistence (NDJSON) + resume.** A survey run streams its classified carriers to a crash-safe NDJSON file when `persist_survey` is set, and on `resume` preloads the already-recorded frequencies so an interrupted web survey continues where it left off — matching toggles in the *Hunt* panel. Mirrors the CLI's `-survey-ndjson` / `-resume`, which remain for standalone runs. (#661) - **Hunt: neighbour-frequency resolution for DMR Tier III / EDACS / Motorola.** Neighbour resolution was P25-only; DMR Tier III, EDACS and Motorola advertise adjacent sites by LCN and each already has a band-plan resolver, but their topology snapshots never applied it. They now resolve a neighbour's frequency via the configured band plan (an unresolved neighbour stays informational, no decode error), and a missing frequency is backfilled once a later observation resolves it. (#660) - **SoapyRemote `stream_mtu` and `stream_window` config.** Both are SoapyRemote stream arguments (`remote:mtu` / `remote:window`) read client-side and forwarded to the server's `setupStream`, so neither could be set via the existing `soapy_remote.args` device kwargs. New per-endpoint knobs send them in `SETUP_STREAM` and size the client's in-flight flow-control credit from the same value so both ends agree — wired through the config (with range and `stream_window >= stream_mtu` validation), the daemon, the config builder, the web form, and the example config. (#664, #665) - **TETRA Viterbi correction-depth histogram** (opt-in via `metrics.detailed_fec`). The control channel scores each recovered BSCH / SCH-HD burst's FEC correction depth decoder-independently (Hamming weight between received and re-encoded bits), surfaced through an optional observer hook the daemon wires to metrics only when the gate is set — so the default deployment does no per-burst work and carries no extra metric family. (#667, #648) ### Changed - **P25 Phase 1 unhandled-TSBK diagnostic now dumps the raw payload (#376).** The first round of CBD/Mt Anakie field diagnostics confirmed the alias is *not* arriving as our control-channel working-model fragment (vendor opcode `0x15`, plain ASCII): zero `cc talker alias`/`fragment` lines fired on either system, even the cleanly-decoding one. The census instead named candidate opcodes (Motorola `0x90` opcode `0x16`; standard `0x15`/`0x16`/`0x30`) but we couldn't tell which carries the alias from the opcode number alone. The `p25: unhandled tsbk` census line now also prints the **numeric** opcode (`Opcode.String()` mislabels vendor opcodes with standard names), and a new `p25: unhandled tsbk payload` line logs the raw 8-byte payload hex, capped at 8 samples per distinct `(MFID, opcode)` so a multi-block alias sequence can be captured and reversed against known RIDs / alias strings without flooding a busy control channel. - **P25 Phase 1 control-channel talker-alias decode is now observable in the daemon log (#376).** A new field-tested system (CBD) locks and populates RIDs but shows blank talker aliases, with heavy TSBK CRC loss on the control channel. The CC alias path was fully wired but logged only at `Debug`, so a field tester running at the normal level saw nothing from it and couldn't tell whether aliases were arriving, completing, or riding an opcode we don't decode. Three diagnostics now surface at `Info`, mirroring the per-`(opcode,MFID)` census the Phase 2 voice composer already emits: - `p25: cc talker alias` once a radio's display name fully reassembles (promoted from `Debug`). - `p25: cc talker alias fragment` on the first vendor-TSBK alias fragment seen per source — so "fragments arrive but never complete" (CRC loss truncating the set before the 10 s reassembly window) is distinguishable from "no fragments at all". - `p25: unhandled tsbk` once per distinct `(MFID,opcode)` we don't dispatch (both vendor and standard namespaces) — a census that names any alias-bearing transport a given site uses that we don't yet decode. The bulk per-frame detail stays at `Debug`. - **P25 Phase 1 voice-channel talker-alias events now carry the system name (#376).** The Phase 1 voice composer published `KindTalkerAlias` without the `System` field (unlike the Phase 2 composer and both CC paths), leaving the `/rids` System column and the `TALKER-ALIAS` message-log line systemless. The alias itself was unaffected. - **Hunt: snap swept candidates to the channel grid and normalise occupied bandwidth** (#648). The sweep reports carriers at FFT-bin centres (~586 Hz bins at 2.4 MS/s), so a real carrier such as a P25 trunk at 440.125 MHz was reported a few hundred Hz off and 4800-baud decoders failed to lock off-centre. The candidate list now auto-detects the channel step (6.25 / 12.5 / 25 kHz) from the discovered frequencies and snaps each candidate to the raster within one FFT bin (without moving genuinely off-grid carriers; jittered detections of one channel merge, strongest SNR winning), and the reported occupied bandwidth is normalised to the nearest standard channel width so a 12.5 kHz channel reads as 12.5 kHz rather than a skewed "11.6 kHz". (#669) ### Fixed - **P25 / DMR wideband voice glitches from an off-nominal DDC rate** (#550). Voice grants tapped off a wideband SDR run through a per-call DDC that rationally resamples toward 48 kHz, but at certain input rates the reduced L/M ratio trips the resampler's caps and lands a fraction of a percent off (e.g. ~48828 Hz from a 6.25 MS/s-derived bin). The control-channel path already clocked its symbol loops from `DDCBank.OutputRateHz`, but the voice path hardcoded the nominal 48 kHz, so the symbol clock drifted off the true phase, periodically slipped, and the vocoder concealed each slip with an audible click. The virtual tuner now surfaces the DDC's actual fractional rate through the composer to every digital voice chain (P25 Phase 1 / Phase 2 / DMR), as a `float64` so the fractional part reaches the receiver's symbol clock intact. Physical SDRs (exact integer rate) are unchanged. (#668) - **MPT 1327 false locks** (#648). The lock gate counted recognised Address codewords but ignored their identity, so a burst of inconsistent cross-protocol false parses — a different system prefix each time — could still reach the confirmation threshold and declare a false control-channel lock (and, being permissive lock-on-first 1200-baud FFSK, win the identify over P25 / DMR that failed off-centre). A lock now requires a minimum number of confirmation codewords (the production decoder sets two) that share a **consistent 7-bit prefix**: a matching prefix corroborates the run in progress, a new or changed prefix restarts the count. A genuine control channel repeats its prefix on every Aloha / AhoyChan and still confirms within a couple of codewords, while random parses never accumulate. (#669, #670) - **TETRA control channels skipped during blind identify** (#648). A recognised TETRA control channel never cleared the 0.40 identify confidence gate ("best: tetra @ 0.16") because its 30 % FEC score depends on descrambling SCH/HD with the cell colour code, which is 0 under blind identify — wrong for any real cell, so every SCH/HD CRC failed and TETRA forfeited the whole FEC weight. The BSCH is always scrambled with colour code 0 and carries the cell's colour code, so blind identify now recovers it from the BSCH and descrambles SCH/HD with the recovered code, letting a real TETRA control channel earn its FEC score and clear the gate. The stale "descrambler will not lock" colour-code warning (the decoder auto-acquires it from the BSCH) is demoted to `Debug`. (#662) ## [v0.4.1] — 2026-06-13 This release is mostly **DMR voice quality, wideband / USRP capture, and the live signal survey**, with the latest Tier III work finally surfaced in the web console. On the voice side, the AMBE+2 decoder (DMR, P25 Phase 2, NXDN, dPMR, TETRA) adopts the same post-synthesis voiced-phase dispersion / DC-block / adaptive-smoothing chain IMBE already had, killing the metallic "tin can" buzz (#644), and the Tier III 2-slot router degrades gracefully so a real call no longer records an empty file when its embedded Link Control never decodes (#644). USRP / SoapySDR sources now track the rate the device *actually* delivers and expose a `master_clock_rate` knob (#550), and the polyphase channelizer decodes cleanly at USRP-friendly rates like 6.25 / 5.0 MS/s where a crude resampler fallback used to leave the control channel deaf (#550). The live survey stops mislabeling tightly-packed DMR / TETRA carriers as analog AM / wide-FM by isolating each candidate before classification and adding an opt-in `-survey-deep` arbiter (#648). Live streaming closes the remaining quality gap to recorded audio — band-limited resampling, deeper buffering, and an optional real-time loudness AGC (#653) — the Plots panels rest on the control channel instead of the DC-spike-buried SDR centre (#557), an out-of-the-box RTL-SDR now defaults to tuner AGC so no-gain configs aren't ~17 dB deaf (#264), and the config builder accepts decimal frequency / tone entry. Finally, the DMR Tier III autoconfig learner and the live-hunt details are now visible in the web console (#638). ### Added - **Hunt features surfaced in the web console.** Recent hunt-family work that previously only reached the logs / raw event stream is now visible in the web UI. The *CC Activity* tab renders the DMR Tier III autoconfig learner's live `dmr.grant.observed` (LCN, timeslot, talkgroup, source) and `dmr.bandplan.learned` (base frequency, channel spacing, confidence) events — given clean typed DTOs instead of raw passthrough — plus the control-channel hunt lifecycle (`cchunt.progress` / `cchunt.failed`). The *Systems* tab shows the active DMR LCN→frequency band plan per system (`GET /api/v1/systems` → `dmr_band_plan`), whether operator-configured or learned over the air, with a "learned live" indicator. The *Hunt* tab now lists each candidate's capture report (control frequency, protocol, lock / skip reason) and the discovered sites with their control channels. (#638) - **USRP/SoapySDR actual sample-rate tracking.** The `soapy_remote` driver now implements the `ActualSampleRate()` extension (via the `getSampleRate` RPC), so when a USRP coerces a requested rate to the nearest integer decimation of its master clock, GopherTrunk builds every per-channel down-converter and symbol clock from the *delivered* rate instead of the requested one — the same #402 correction RTL-SDR already had. A new `sdr.soapy_remote[].master_clock_rate` option sets the USRP master clock in Hz so a target `sample_rate` lands on an exact divisor (e.g. `61_440_000` for a B210 to stream `6_144_000` cleanly). (#550) - **Live-stream loudness AGC** (#653). An optional real-time envelope-follower AGC (`audio.live_loudness`, default off) on the digital decoded-PCM live tap, so the live stream tracks the loudness-normalized recordings instead of playing raw PCM. It wraps only the digital stream path — the on-disk WAV keeps its own EBU R128 normalization and is never double-processed, and analog FM live audio (already shaped by the composer's AGC) is untouched. ### Changed - **Plots view defaults to the control channel, not the SDR centre.** The Constellation, Symbol scope, Eye diagram, Mixer, Tuning and Histogram panels now rest their view on the system's control-channel frequency (resolved from config and reported per-SDR as `control_channel_hz` on `GET /api/v1/spectrum/devices`) whenever Hold is off and no call is active — so a freshly opened panel lands on a decodable channel clear of the centre DC spike instead of the useless SDR centre. An active call still takes precedence, and the view glides back to the control channel when the call ends; the Hold label shows *following call* or *on control channel* accordingly. SDRs with no control channel in their passband keep the previous centre default. (#557) - **RTL-SDR defaults to tuner AGC when no `gain:` is configured** (#264). An out-of-the-box pure-Go RTL-SDR front end was left in an undefined gain state: bring-up ran the tuner init array (which leaves the R82xx VGA at +0 dB) but never called `SetGainMode`/`SetGain`, and the daemon only applied a gain when `gain:` was non-empty — so the VGA pin that AGC mode writes (+16.3 dB) never landed and the dongle ran ~17 dB low, failing to decode on every protocol while the same device worked in SDR# / PDW / TTT. Bring-up now establishes AGC (and the VGA pin) at open for every device, the daemon resolves an empty/omitted `gain:` to auto (-1) for both local and `rtl_tcp` devices, and the no-gain pool log is now an informational note rather than a deafness warning. A configured gain still overrides AGC. - **Live survey: `-survey-deep` arbiter and a corrected AM / FM split** (#648). In a DMR / TETRA-dense band the blind classifier labeled almost every carrier `am` (including a 61 dB-SNR one) because the AM gate fired on envelope variance alone and never consulted the discriminator's `IFStd`. The gate now also requires low `IFStd` (little angle modulation), so a constant-envelope DMR carrier or an FM repeater with CTCSS falls to the FM split (`nbfm`) instead of a contradictory `am`; the cyclostationary baud search band was widened to ~20 kHz so TETRA's 18 kbaud line is searchable. A new opt-in `-survey-deep` flag also hands narrowband (≤25 kHz) analog-classed carriers to the authoritative siglab identify (lock-only), so a trunked control channel the blind classifier missed can still be found; the default survey stays fast. - **Config builder accepts decimal frequency / tone entry.** A controlled `` reported an intermediate value like `131.` as empty, which reset the field to `0` and wiped the keystroke, so fractional frequencies and tones could not be typed. Fields with a fractional step now route to a buffered text input (modeled on `HzField`) that preserves mid-edit text and emits the parsed number; integer fields keep the native spinner. The float-backed audio tone Frequency, Tolerance, Magnitude threshold, and Squelch (dBFS) fields opt into the decimal path. ### Fixed - **Wideband polyphase decode at 6.25 / 5.0 MS/s (USRP-friendly rates).** With `tuner_strategy: polyphase`, the channelizer's per-tap fine-tune resampler could silently emit the wrong sample rate at input rates whose bin rate is short on factors of two (e.g. 6.25 MS/s ÷16 = 390625 Hz): the exact 48 kHz ratio needed L=384, tripped `rationalRatio`'s L≤64 cap, and fell back to a crude integer decimator that produced 48828 Hz — a 1.7 % symbol-clock error that left the control channel deaf (P25 NID BCH uncorrectable). The fallback now picks the closest ratio under the caps (≤0.06 % error), and the wideband engine builds each receiver from the rate the bank *actually* emits (`Bank.OutputRateHz`) rather than a hardcoded 48 kHz. The DDC path was already exact and is unchanged. (#550) - **Metallic "tin can" DMR voice: AMBE+2 synthesis parity with IMBE** (#644). Once the timeslot fix made Tier III speech intelligible it sounded metallic/buzzy — the AMBE+2 decoder (DMR, and also P25 Phase 2 / NXDN / dPMR / TETRA) synthesized voiced harmonics fully phase-coherently, radiating the classic buzzy MBE impulse train that IMBE had already fixed but AMBE+2 never adopted. AMBE+2 now runs the same three post-synthesis stages as IMBE: TIA-102.BABA §6.3 voiced-phase regeneration (per-harmonic dispersion scaled by the unvoiced fraction, so fully-voiced frames stay bit-identical), DC-block high-pass ahead of the AGC, and error-rate adaptive smoothing. The DMR voice chain now forwards its per-frame Golay corrected-bit count through the error-aware sink so smoothing engages on real bursts. - **Empty DMR Tier III recordings: graceful slotRouter phase fallback** (#644). A real Tier III call could record a 0-byte `.raw` and a header-only 44-byte WAV: the 2-slot interleaved decoder's slotRouter dropped every voice superframe until a CRC-valid embedded Link Control named the call's talkgroup and bound its phase, but real outbound embedded signalling is still capture-pending, so on a carrier whose embedded LC never decodes the router bound nothing and the whole call was dropped. The router now degrades gracefully — a matching LC still binds (and can re-bind/correct) the slot, a foreign-talkgroup LC positively marks the other phase, and otherwise, after a short grace window with no LC, it falls back to the active slot's phase so audio records. Adds `lc_superframes` telemetry and a one-shot log when a call records via the fallback. - **Live-vs-recorded audio quality gap closed** (#653). Live and recorded audio start from identical decoded 8 kHz / 16-bit / mono PCM, so the gap was entirely in the live transport + browser playback chain. Three fixes: a Blackman-windowed polyphase `SincResampler` (with continuous cross-chunk state) replaces linear interpolation in the WorkletSink, so a browser that rejects the 8 kHz AudioContext and falls back to 44.1/48 kHz no longer leaks aliasing images above the 4 kHz input Nyquist (first alias image now >40 dB down); the ring-buffer prime cushion grows 0.15 s → 0.25 s and the per-subscriber publisher channel 64 → 256 frames so transient HTTP-write stalls no longer drop audio; and the optional live loudness AGC (below) tracks the normalized recordings. - **Live survey: isolate dense carriers so DMR isn't mislabeled AM / wide-FM** (#648). On a tight carrier grid (DMR at 12.5 kHz) the survey mis-read nearly every signal as `am`/`wfm` with absurd occupied bandwidths (1000–2200 kHz) and then ran the analog-FM analyser on them, emitting bogus "active / CTCSS" results. Two coupled causes, both from never isolating a candidate from its neighbours: the occupied-bandwidth walk on the full-rate capture bridged adjacent carriers into one giant span, and modulation features extracted on the ±24 kHz channel let neighbours leak into the FM discriminator and destroy the baud line. The occupied-bandwidth estimate now takes a per-candidate neighbour spacing cap, and when a neighbour sits inside the wide channel the classifier isolates the carrier by decimating to ±12 kHz with the gentle resampler — so a digital carrier is correctly handed to the siglab identify → DMR. ## [v0.4.0] — 2026-06-12 This release is mostly about **DMR**. The receiver now decodes real over-the-air control channels end-to-end (a symbol-AGC normalises the matched-filter level so live BPTC/RS FEC actually passes), and Tier III gained the pieces needed to follow a trunked system you didn't hand-configure: **LCN band-plan autoconfiguration** learns the channel layout from the wideband stream and hot-swaps it into the running control channel (#638), the **CSBK opcode table** was rebuilt to the ETSI values so the control log stops mislabelling broadcasts and grants (#640), and **data grants, MBC assembly, inbound/ack CSBKs, and the embedded Reverse Channel** are now parsed (#643, #646). On the voice side, Tier III recordings default to the 2-slot interleaved decoder so two timeslots no longer splice into "encrypted"-sounding noise (#644), and the voice-grant LCN is read from the correct bits so the band-plan learner converges (#639). Audio playback gets **per-call loudness normalization** (pure-Go EBU R128 / BS.1770, no ffmpeg) so calls from different talkgroups play back at a consistent level (#627), plus an onset-clip fix that caps per-frame AGC gain at the limiter knee (#635). The Plots hub adds the **Mixer** plot — completing parity with OP25's six Plots tabs — and the Spectrum panel gains a live **spectrum-analyzer trace** above the waterfall (#634). Finally, a wideband **symbol-clock drift** fix lands the #402 effective sample-rate correction on the Tier-II/III and virtual-voice DDCs so marginal wideband captures decode (#633), and a live **Hunt** no longer dead-ends when no control SDR is configured (#641). ### Added - **Mixer plot — the last of OP25's Plots tabs.** A new **Mixer** plot (web `/plots/mixer`) renders the channel-baseband power spectrum two ways, completing parity with OP25's six Plots tabs (Spectrum, Constellation, Symbol, Datascope/Eye, Raw Mixer, Tuned Mixer). **Raw mixer** shows the channelized baseband as the receiver first sees it, with an amber marker on the carrier-offset estimate; **Tuned mixer** re-mixes that same window by the estimate so a locked loop pulls the carrier onto the centre line. Comparing the two shows the carrier-recovery correction at a glance. It runs the same parallel P25 receiver the other scopes use (`WS /api/v1/diag/mixer`), reuses the wideband Spectrum FFT helper, and needs no new receiver tap — the tuned view is reconstructed from the loop's own `carrier_offset_hz`, so it works for both C4FM and CQPSK. See [docs/mixer.md](docs/mixer.md). - **Spectrum-analyzer trace above the waterfall** (#634). The Spectrum panel now draws a live power-vs-frequency curve (the "spectrum analyzer" line, like SDR#'s top panel) above the existing waterfall. It reuses the same `SpectrumFrame` WebSocket stream, the same `[-100, 0]` dBFS range, and the same FFT-shifted bin→x mapping, so a peak in the analyzer lines up vertically with its streak in the waterfall below. It has its own 20 dB grid and a fill under the curve; hover and click-to-tune now work on either view. - **Per-call loudness normalization (EBU R128 / BS.1770), pure Go** (#627). An optional integrated-loudness stage normalises finished recordings to a target LUFS — the same thing rdio-scanner gets from ffmpeg's `loudnorm`, but with no CGO and no ffmpeg, so the single-static-binary guarantee holds. A new `internal/dsp/loudness` package implements K-weighting biquads (designed by bilinear transform for any rate, so it works at 8 kHz voice), gated integrated-LUFS measurement, and 4× oversampled true-peak. It mirrors ffmpeg's two-pass linear `loudnorm`: measure the whole call, apply a single gain toward the target (clamped to ±`max_boost_db`), then back off so the true peak stays under the dBTP ceiling — pure gain, no compression, so within-call dynamics are preserved. Opt-in via `recordings.normalize.{enabled,target_lufs, true_peak_dbtp,max_boost_db}`, with an `apply_to` switch (`recording` / `distributed` / `both`) choosing whether the on-disk WAV, the outbound broadcast/stream MP3, or both get normalised. `gophertrunk decode -normalize` runs the same pass on a captured `.raw` for offline A/B. Off by default; see [docs/opt-in-features.md](docs/opt-in-features.md). - **DMR Tier III trunk autoconfiguration (LCN band-plan learning)** (#638). A Tier III control channel transmits only a Logical Channel Number in voice grants, never an absolute frequency, so following voice previously required a hand-configured `dmr_band_plan` — without it every grant was dropped with `decode.error stage=no-bandplan`. GopherTrunk can now learn that band plan from its wideband acquisition: it observes the granted LCNs, detects which RF carriers key up in response across the dongle's IQ band, decode-confirms each is a live DMR burst, and fits the base frequency + channel spacing (snapped to the 6.25/12.5/25 kHz grids) for the whole LCN enumeration. The learned plan is hot-swapped into the running control channel — previously-dropped grants start following voice immediately — and written back to the config file (comment-preserving) so it survives a restart. Auto-enables for any planless wideband DMR Tier III system; a configured plan stays authoritative and disables learning. ### Changed - **DMR Tier III CSBK opcode table rebuilt to the ETSI values** (#640). The Tier III CSBKO constants were wrong in several places, so the control-channel log mislabelled traffic and looked sparse next to dsd-neo: `0x28` was shown as "Preamble" but is really `C_BCAST` (broadcast announcements — every "opcode=Preamble" line was actually a broadcast), the voice-grant opcodes were swapped, and `SystemInfo`/`AdjStatus` were invented standalone opcodes that never matched real traffic. The table is rebuilt to ETSI TS 102 361-4 (names mirror dsd-neo: `C_ALOHA`, `C_AHOY`, `C_ACKD`, `C_BCAST`, the PV/TV/BTV/PD/TD grants, `C_MOVE`, real `Preamble` at `0x3D`); `C_BCAST` announcements (`Gen_Site_Params`, `Adjacent_Site`) now fold into the topology model, and every standard CSBK is logged at debug so the dominant Aloha beacon is visible instead of being silently consumed after lock. Pinned with real off-air vectors. - **DMR Tier III data grants, MBC assembly, and inbound CSBKs** (#643, issue #626). Several CSBK opcodes and the multi-block-control data types were recognised but never acted on. Data-channel grants are now handled: `BTV_GRANT` (a broadcast *voice* call) is followed like a TV grant, while `PD_GRANT`/`TD_GRANT` (*data* grants) feed the LCN learner but are deliberately not published as voice grants, so the engine never retunes a voice device onto a packet channel. MBC header + continuation bursts are now buffered by a per-colour-code assembler that closes on the `LB=1` terminator and dispatches channel-grant opcodes (per-block BPTC FEC gates each block). Inbound/ack CSBKs — `C_RAND`, `C_AHOY`, and the `C_ACKVIT`/`C_ACKD`/`C_ACKU`/ `NACK` family — are now parsed and surfaced at debug, with address offsets pinned to real off-air vectors. (USB packet-data payloads remain out of scope — this is a trunking scanner, not a data decoder.) - **DMR embedded Reverse Channel decode** (#646). The earlier `C_RAND`/`C_AHOY`/`ACK` additions are uplink/ack CSBKs and were over-broadly labelled "reverse-channel"; that terminology is corrected to "inbound (uplink)". The actual Reverse Channel — which is not a CSBK but rides a burst's embedded-signalling field — is now decoded from the single-fragment (`LCSS=Single`) embedded field a downlink receiver actually observes, surfaced on the voice superframe and debug-logged. (The (32,11) FEC parity is preserved but not yet applied — capture-pending, like the EMB QR(16,7) and MBC CRC.) ### Fixed - **Empty DMR Tier III recordings when the embedded LC doesn't decode (#644 follow-up).** The 2-slot interleaved decoder's slot router dropped *every* voice superframe until a CRC-valid embedded Link Control named the call's talkgroup. Real outbound embedded signalling is still capture-pending (the EMB FEC / 5-bit CRC constants are unvalidated against live air), so on a carrier whose LC never decodes the router bound nothing and the call recorded empty files — a 0-byte `.raw` sidecar and a header-only WAV. The router now degrades gracefully: a matching embedded LC still binds (and corrects) the slot, but after a short grace window with no LC it falls back to the active slot's phase so audio records. Foreign-talkgroup LCs still positively exclude their slot, so a call genuinely absent from the carrier still records nothing. The DMR decode-quality log now reports `lc_superframes` and logs once when a call records via the phase fallback, so LC-vs-fallback routing is visible. - **Metallic "tin can" DMR voice — AMBE+2 brought to IMBE synthesis parity (#644 follow-up).** With the timeslot fix above the speech became intelligible but sounded metallic/buzzy ("like someone speaking into a tin can"). The AMBE+2 decoder (DMR, and also P25 Phase 2 / NXDN / dPMR / TETRA) was synthesizing voiced harmonics fully phase-coherently, which radiates a buzzy impulse train — the classic from-scratch-MBE artifact. It now adopts the three post-synthesis stages the IMBE decoder already had: §6.3 voiced-phase regeneration (per-harmonic phase dispersion scaled by the unvoiced fraction — the de-buzz), a DC-removal high-pass ahead of the AGC, and error-rate adaptive smoothing. The DMR voice chain now also forwards the per-frame Golay corrected-bit count to drive that smoothing. Clean, fully-voiced audio is bit-identical to before (dispersion and smoothing are inert on a clean, voiced frame), so only the metallic timbre changes. - **Garbled audio on recorded DMR Tier III voice (#644).** A DMR carrier is 2-slot TDMA, so the demodulated dibit stream interleaves both timeslots' bursts. The per-call voice chain decoded it with the single-slot decoder, which locks burst A correctly but then grabs the *other* timeslot's bursts for B–F — splicing two unrelated calls' AMBE frames into each superframe, so the vocoder rendered structured noise that "sounds encrypted" on a clear channel. DMR Tier III now defaults to the 2-slot interleaved decoder, which pulls each call's own timeslot out and routes it to its talkgroup by the embedded Link Control. The decoder auto-detects the on-air same-slot cadence — 264 dibits (no inter-burst CACH) vs 288 (a CACH precedes each burst on outbound air) — by locking onto the cadence whose bursts B–E reassemble a CRC-valid embedded LC. `dmr_interleaved_voice` is now a tri-state override (unset = protocol default — on for Tier III; set true/false to force). - **DMR Tier III voice-grant LCN read from the wrong bits (#639).** The TalkGroup/Private voice-grant CSBK parser used the wrong field layout: it read the "LCN" from the last payload octet — which is actually the low byte of the 24-bit source (subscriber) address — so the decoded Logical Channel Number changed with every transmitting radio, even on a system with only a couple of fixed channels. The grant content actually leads with a 12-bit Logical Physical Channel Number (LPCN), followed by the target then source addresses (per ETSI TS 102 361-4, cross-checked against the dsd-neo decoder). The parser now extracts the LPCN from the leading payload bits and the addresses from their correct octets, and the LCN is widened from 7 to 12 bits end-to-end (parser, band-plan resolver, autoconfig learner, config, events). This unbreaks both operator-supplied `dmr_band_plan` tables and the LCN autoconfiguration learner, which previously never converged because it saw a fresh LCN on every call. (Encrypted/Emergency are no longer reported from a DMR grant — those are signaled in the voice Link Control, not the channel grant CSBK.) - **DMR now decodes real over-the-air control channels.** The DMR receiver previously decoded only the zero-offset, level-matched synthesized fixtures; on a real SDR capture every BPTC(196,96) payload came back uncorrectable, so Tier III never locked ("dmr/tier3: BPTC uncorrectable") and Tier II reported a bogus instant lock then nothing. Root cause: the unit-energy RRC matched filter has a DC gain of ~3.1, so the 4-level symbol centres landed far above the slicer's fixed thresholds and the inner symbols collapsed onto the outer rails. A symbol-AGC now normalises the matched-filter level to the slicer's scale (the same calibration the P25 Phase 1 receiver runs, #275), so a real Tier III TSCC decodes cleanly and locks end-to-end through the wideband channelizer. - **DMR CSBK CRC was rejecting every real burst.** The CSBK CRC-16 used the wrong convention (init 0xFFFF, stored as the bitwise complement), which only validated the synthesized round-trip fixtures. Real ETSI Tier III CSBKs use CRC-CCITT (init 0x0000) XORed with the mask `0x5A5A` (TS 102 361-1 §B.3.11); cleanly-decoded off-air Aloha and Preamble bursts now pass and are pinned by a regression fixture. - **DMR Tier III C_ALOHA opcode corrected** from `0x04` to `0x19` (the value real TSCC beacons carry), so the control channel locks on the Aloha CSBK. - **DMR Tier II "instalock".** Tier II declared `cc.locked` on any slot-type Hamming decode, so a false sync match on noise forged an instant lock (often `cc=15`) that then produced nothing. The lock is now gated on a FEC-validated Voice LC Header (BPTC + RS both pass), mirroring Tier III's lock-after-CRC discipline. - **Symbol-clock drift on wideband DMR/voice down-converters (#402)** (#633). The control-channel decoder already built its DDC from the SDR's *actual* delivered sample rate (the RTL2832U quantizes a requested rate to its resampler divisor, so a non-exact-divisor rate like 2.048 MS/s streams slightly off), but the wideband Tier-II/III engine and the wideband virtual voice taps still built their per-channel DDCs from the raw configured `sample_rate`. On a non-exact rate the tap output wasn't truly 48 kHz, so the DMR receiver's Mueller-Müller clock carried a standing error and the payload failed BPTC/RS FEC even though sync still correlated — the field symptom of "signals look weak / won't decode" on a wideband RTL-SDR that decodes fine in SDR#/SDRTrunk. The effective-rate correction now applies to both wideband paths; exact-divisor configs (2.4 / 0.96 MS/s) are unchanged. - **Onset clipping from runaway AGC gain on quiet call starts** (#635). The IMBE/AMBE AGC computed `gain = TargetPeak/envelope`, bounded only by a large `MaxGain`. When a call opened with a near-silent frame the envelope seeded low and the next louder frame — or a frozen bad-frame replay reusing the held gain — was multiplied by a huge gain, slamming every sample into the soft limiter (field captures showed ~65 % of samples clipped on short onsets). `AGC.Apply` now adds a one-sided, content-aware ceiling that never amplifies a frame's own peak past the limiter knee, so ordinary frames and the attack/release dynamics are unchanged — it can only reduce clipping, and it applies to frozen replays too. - **Live Hunt failed when no control SDR was assigned** (#641). A live hunt always bailed with "no SDR with an IQ broker available for a live hunt" whenever no trunked system was configured — the auto-select path only considered a spare voice SDR or the control SDR, but the control serial is only set when a trunked system exists. The natural discovery workflow (point one SDR at the air to find an unknown system, nothing configured) left that serial empty and dead-ended even with a usable SDR sitting idle, as did wideband-only rigs and dongles with a blank USB serial. A last-resort fallback now picks any pooled SDR that has a broker. - **`iq-capture` now warns on dropped chunks.** A non-zero `drops` count means the capture writer fell behind the IQ stream (subscriber buffer full) — not an SDR overflow — leaving time gaps that corrupt downstream decode. The finish log now emits a `WARN` explaining the cause and the remedy (faster storage, lower sample rate, fewer concurrent sinks) instead of only printing the count. ## [v0.3.9] — 2026-06-11 This release is about **live audio you can actually hear** and a friendlier config path. Digital voice (P25 Phase 1/2, DMR, NXDN) now reaches the web console's live stream — previously only analog FM and disk recordings carried sound, because digital frames are decoded to PCM only inside the recorder and were never fanned to the live consumers (#598). The browser player is rebuilt around an **AudioWorklet ring buffer** with a single continuous resampler, so chunk boundaries no longer click and network jitter degrades to brief silence instead of audible re-syncs (#629); the live `AudioContext` is pinned to 8 kHz to match the recorded WAV quality (#598), and a host-speaker echo from double-playing digital audio is gone (#598). The **Config Builder and web UI** get a shared RadioReference login (the SOAP app key is built into the binary), an in-place config-file browser and hot-swap/reload, and a smoother connect screen (#621). On the DSP side: the P25 IMBE vocoder is aligned with the OP25/JMBE references (pitch-dependent prediction, DC block, adaptive smoothing), over-driven P25/DMR voice clipping is fixed with a soft limiter and per-call voice telemetry, `voice_taps` is uncapped, Airspy real-ADC IQ conversion and rate/probe-gain bugs are fixed (#454), and a DC-spike-avoidance LO offset lands on the live control path (#402). Three new **Basics / Intermediate / Advanced** guides extend Getting Started (#620). ### Added - **Three-level learning path: Basics / Intermediate / Advanced guides** (#620). The Getting Started funnel now continues past your first recorded call with three pages that build level by level and cross-link out to the existing detailed docs rather than duplicating them: `guide-basics` (everyday web- console operation, playback, talkgroup tidy-up, bookmarks/radio IDs, scanning, feed sharing), `guide-intermediate` (`config.yaml`, multi-dongle pools, alias files, paging/tone alerts, Hunt, network access/hardening), and `guide-advanced` (TUI cockpit, signal scopes, SigLab, the other receivers, remote SDRs, and the REST/gRPC/Prometheus/rigctld APIs). Wired into the Getting Started nav group. - **Config Builder & web UI: RadioReference login, config browsing/hot-swap, connect UX** (#621). The Config Builder gains a shared RadioReference login block (username + password only — the SOAP app key is built into the binary), surfaced inline in the "Add from RadioReference" modal so browsing prompts for credentials in place, plus a prominent "Browse…" picker that lists available and previously-created configs with folder, modified time, size, and validity. The web console pre-fills the server URL with the device's own origin (with a "Use this device's address" button), offers to open the Config Builder when the daemon has no config file, and adds a Settings config-file picker that hot-swaps the active config via live Reload or full Restart. Backed by a new gated `POST /api/v1/config/activate`. - **DC-spike-avoidance LO offset on the live control channel** (#402). A new opt-in `dc_avoid` flag (with optional `dc_avoid_offset_hz`) on a control SDR's `sdr.devices` entry tunes the hardware LO a fixed offset *below* the control-channel frequency and mixes the channel back to baseband in the down-converter — so the live decode runs off the front-end DC spur, 1/f noise and its own I/Q-imbalance image, the same offset tuning SDRTrunk/OP25 use. This closes the live-vs-replay gap on marginal urban sites where the channel decodes cleanly off-channel (in replay) but accumulates TSBK-CRC / NID-BCH failures live with the channel sitting at zero-IF. Off by default (`dc_avoid: false`); `dc_avoid_offset_hz: 0` auto-selects `sample_rate/4`. - **Per-call voice audio-quality telemetry** for diagnosing decode/synthesis problems. The IMBE decoder accumulates a `VoiceStats` summary (pitch/f0, harmonic count, voiced fraction, AGC gain, output peak/RMS/crest, and limited-sample percentage); the recorder logs it per call at `DEBUG` as `recorder: voice audio quality`, escalating to `WARN` when the output is clipping. `gophertrunk decode` prints the same summary for a captured `.raw` sidecar (`-stats`, default on), so audio quality can be triaged offline. ### Changed - **`voice_taps` is no longer capped at 8.** A wideband dongle can now host any number of concurrent virtual voice DDC taps. Each tap runs its own DDC so CPU scales roughly linearly per tap; the daemon logs a warning above 16 so an accidental large value doesn't quietly peg a core. - **P25 IMBE voice quality: align the vocoder with the OP25 / JMBE reference decoders.** Three changes ported after comparing GopherTrunk's mbelib-lineage decoder against OP25's `imbe_vocoder` and DSheirer's JMBE: - **Pitch-dependent spectral-amplitude prediction.** The cross-frame log-amplitude prediction coefficient is now a function of the harmonic count L (`ρ = 0.4` for L≤15, `0.03·L−0.05` for 16–24, `0.7` for L≥25), matching both reference decoders, instead of a fixed 0.65. The old constant over-predicted high-pitch / low-L frames and smeared the previous frame's spectral envelope onto them — the main remaining cause of poor female-voice intelligibility. Measured on the field capture, the female call's crest factor rose from ~7.8 to ~9.8 (closer to the raw synthesizer's ~11). - **DC-removal high-pass** on the synthesized PCM (first-order, pole 0.99), matching OP25's `dc_rmv.cc`, so a DC offset no longer wastes AGC headroom. - **Adaptive smoothing** driven by the channel FEC error rate (ported in spirit from JMBE `IMBEModelParameters`): a running ε_R estimate caps error-induced amplitude spikes, reclaims obviously-voiced harmonics, and mutes hopeless frames (ε_R > 0.0875). Thresholds are expressed relative to a running local-energy estimate (vs the reference's fixed-point absolute constants) and are inert on a clean channel, so well-decoded audio is byte-identical. The P25 Phase 1 chain now threads the per-frame corrected-bit count to the decoder (`voice.ErrorAware`); other protocols are unaffected. ### Fixed - **Airspy R2 / Mini received nothing — web spectrum showed only the DC spike.** The Airspy is a real-sampling receiver whose host-side converter decimates by two, so the delivered IQ rate is half the programmed device rate. The driver was sending the requested rate straight to firmware, so `sample_rate: 3_000_000` ran the device at 3 MSPS (1.5 MHz of IQ) while the rest of the pipeline assumed 3 MHz — a 2× mismatch that mis-tuned every decoder. The driver now programs the device at twice the requested IQ rate, so a 3 MHz IQ rate correctly selects the Airspy's 6 MSPS mode. - **`gophertrunk sdr list --probe` reported empty gains `[]` for Airspy and HackRF.** The per-device open path rebuilt `sdr.Info` without copying the gain ladder that `Enumerate` populates, so the probe (which reads the opened device) saw nothing. Both drivers now carry the ladder onto the opened device, matching RTL-SDR. Gain values are in tenths of dB (Airspy 0–500, HackRF 0–560); see `config.example.yaml`. - **Live audio failed silently.** When `audio.enabled: true` but the sink couldn't open, the player logged a single WARN and ran headless, leaving no signal as to why nothing played. Backend init failure now logs at ERROR with the reason, and the cause is surfaced via `GET /api/v1/audio` (`backend_error`) and `Stats`. The direct-ioctl path (`device: "ioctl:hw:C,D"`) now explains that it pins S16_LE mono at the configured rate — which onboard codecs typically reject — and points at the default libasound device, which resamples. - **P25/DMR voice was over-driven into clipping (robotic, "female voices especially awful")**. The MBE-family AGC carried a `MinGain` floor of 10, forcing at least a 10× gain onto a synthesizer that already emits near-int16 scale (raw crest factor ≈11). Every loud frame was amplified ~10× straight into the hard clip, flattening speech to a crest factor of ~3 and distorting it — independent of the demod, which decoded cleanly. The floor is now 0.05 (the AGC may attenuate), the hard clip at ±32767 is replaced with a tanh soft limiter, and the target/attack were retuned against the field C4FM + CQPSK captures. Decoded female speech now lands at crest ≈7.8 with ~0.02% limited samples — in line with a reference decoder (Trunk Recorder) on the same speaker — instead of crest ≈3.3 railed at full scale. - **Daemon panic on shutdown** finalizing a dormant post-segment recording session: `WavWriter.Close` dereferenced a nil writer. The session close now guards a nil WAV (and `WavWriter.Close` is nil-safe), so Ctrl-C no longer panics and the final recording is finalized cleanly. - Airspy R2 / Mini now decode correctly (#454). The driver treated the receiver's real ADC stream as interleaved I/Q, producing a huge quadrature imbalance (~78°) and no image rejection (~3 dB) so nothing locked. The Airspy is a real-sampling front end: its samples are now converted to complex baseband on the host (Fs/4 translation + half-band Hilbert, decimate-by-two), matching libairspy's IQ modes and restoring image rejection (~70 dB). - **Live digital voice was silent in the web console** (#598). P25 Phase 1/2, DMR, and NXDN reach the composer as raw vocoder frames (`WriteRawFrame`), which only the recorder consumes and decodes to PCM; the tone-out detector, live player, and audio publisher implement `WritePCM` only, so they never saw digital audio and the live stream stayed silent even while disk recordings were audible (analog FM worked because its chain calls `WritePCM` directly). The recorder now exposes a decoded-PCM tap that the daemon fans to the live consumers, so every decoded sample is streamed exactly once. - **Live stream dropped PCM when the publisher had no cached grant** (#598). `AudioPublisher.WritePCM` discarded all PCM unless it held a cached grant for the device serial, but that cache is fed by an events-bus subscription that drops events into a momentarily-full channel — so on a busy control channel (especially with multiple voice taps) a missed `CallStart` silenced the live stream for the call. Neither stream consumer reads the frame's grant, so the requirement was pure fragility; PCM now fans to unfiltered and serial-only subscribers regardless of grant state (talkgroup-filtered subscribers still need a grant to evaluate their predicate). - **Host-speaker echo from double-playing digital audio** (#598). Digital voice was being played on the host speaker twice, producing an echo; the duplicate playback path is removed. - **Web audio player rebuilt on an AudioWorklet ring buffer** (#629). The per-chunk `AudioBufferSource` scheduler is replaced with an AudioWorklet that reads from a ring buffer through a single `LinearResampler` spanning the whole stream — so non-default analog rates (and browsers that reject the 8 kHz context) no longer reintroduce per-chunk boundary artifacts, and underruns emit brief silence instead of an audible cursor re-align under network jitter. Falls back to the previous scheduler where AudioWorklet is unavailable. - **Live `AudioContext` pinned to 8 kHz** (#598) to match the recorded-WAV quality, so the live stream and recordings sound the same. ## [v0.3.8] — 2026-06-10 This release adds a pure-Go **LoRa / LoRaWAN receiver** (#586) and hardens the daemon and several hardware paths. One SDR is channelized into parallel LoRa sub-channels and decoded through the full PHY (dechirp/FFT, Gray/de-interleave/ Hamming FEC, de-whitening, CRC) with SF7–SF12 auto-detection; LoRaWAN 1.0.x frames are MAC-decoded and, with operator session keys, MIC-verified and decrypted, persisted to `lora_log`, served at `GET /api/v1/lora/frames`, and rendered on a new `/lora` panel. Recording gains an opt-in `recordings.skip_encrypted` flag (#607). The daemon **never stops silently** anymore — component panics are recovered and logged, and a soft memory limit plus a runtime heartbeat bound and surface the process footprint (#606, #492). On the fix side: P25 IMBE female-voice intelligibility and a high-pitched recording onset (#605), Airspy USB initialisation (#454), HackRF interface-claim on macOS (#511), live-audio playback in Chrome via Web Audio (#598), and the symbol-domain scopes now default to the control SDR (#402). ### Added - **Skip recording encrypted calls** (#607). A new opt-in `recordings.skip_encrypted` flag suppresses WAV/raw files for calls the operator can't decode (default `false` keeps recording everything; the call log still notes encryption). The recorder gates at call start when a control-channel grant already flags encryption (P25 Phase 1, DMR, NXDN, EDACS, TETRA), and mid-call when encryption only surfaces on the traffic channel (P25 Phase 1 LDU2 Encryption Sync, or a P25 Phase 2 compressed grant) — the in-progress files are closed and deleted and no `CallComplete` is published, so the partial never reaches the upload feeds. Wired through the YAML config, the settings PATCH API + YAML writer, the TUI settings panel, and the web Config Builder. - **LoRa decoding** (#586). A new pure-Go, zero-CGO LoRa receiver decodes the LoRa physical layer (chirp dechirp/FFT demodulation, preamble/sync/SFD acquisition with carrier-offset and timing recovery, Gray/de-interleave/ Hamming FEC, de-whitening and CRC) with spreading-factor auto-detection across SF7–SF12 and bandwidths 125/250/500 kHz. One SDR is split into several parallel LoRa sub-channels via the tuner channelizer/DDC bank. LoRaWAN 1.0.x frames are MAC-decoded and, when operator session keys are supplied, the MIC is verified and the payload decrypted (no key recovery). Configure under `lora.channels`; decoded frames persist to the `lora_log` table, are served at `GET /api/v1/lora/frames`, and render live on the new `/lora` web panel. ### Changed - **Daemon never stops silently + bounded memory footprint** (#606, #492). A live run could halt mid-decode with no shutdown/fatal/panic line — the hallmark of an external SIGKILL (OS memory-pressure killer) or an unrecovered goroutine panic. The daemon now installs a deferred `log.Recover()` panic guard on the component spawn path, the daemon-run and IQ-capture goroutines, the rtltcp reader, the iqtap fanout, and all four composer voice chains, so a panic becomes a logged ERROR + clean shutdown instead of a process kill. A soft memory limit is set at startup (`GOMEMLIMIT` → `diagnostics.memory_limit_mb` → ~70 % of physical RAM), a periodic runtime heartbeat (`diagnostics.heartbeat_seconds`, default 60 s) logs uptime/goroutines/heap so a leak or pre-kill footprint is visible in the timeline, and `net/http/pprof` is available behind `GOPHERTRUNK_PPROF`. ### Fixed - **P25 IMBE female-voice intelligibility + high-pitched recording onset.** Follow-up to the §6.3 voiced-phase regeneration (#600). Two corrections to match the reference imbe_vocoder/mbelib so female (high-pitch, mostly-voiced) speech is no longer rendered as noise: - The dispersion is now a **bounded per-frame offset on a coherent phase memory** (`PHIl = PSIl + offset`) instead of a full `[−π,π)` step *accumulated* into the phase memory every frame. The old random walk decorrelated the upper harmonics into noise within a few frames; a confound-free A/B on a sustained 220 Hz vowel shows the reference model is ~12 % more periodic/harmonic. - The offset magnitude is **scaled by the unvoiced-harmonic fraction** (`numUv/L`), so a mostly-voiced frame gets near-zero dispersion (stays intelligible) while noise-dominated frames still de-buzz. Fully-voiced frames are now synthesized coherently, as in the reference. - The **idle-carrier mute now engages on the first frame at a transmission onset** instead of leaking one ~352 Hz buzz frame. ~60 % of field calls opened with that leaked frame — the "highly pitched beginning" a user reported (worse on CQPSK, whose warm-up is longer). The run-threshold guard that protects a lone idle frame *inside* speech is unchanged. - **Airspy device initialisation** (#454). The pure-Go Airspy driver's USB vendor request opcodes were systematically wrong; they now match libairspy's `airspy_commands` enum (`SET_SAMPLERATE`=12, `GET_SAMPLERATES`=25, gain/freq opcodes, …). `SetSampleRate` is now a vendor-IN transfer with the rate carried in `wIndex` (the firmware NAK'd the previous vendor-OUT, surfacing as "set sample rate failed: protocol error"), the bogus host-side `SET_SAMPLE_TYPE` command is gone, and the bias-tee uses a GPIO write. `Open` resets the receiver and retries on transient device-gone errors, and the device pool now normalises `AIRSPY SN:` / `airspy_sn:` serial aliases when matching config hints. Includes opt-in real-hardware tests (`make test-airspy-real`) and a Windows WinUSB interface-recipient/associated-interface control-transfer fallback. - **HackRF now claims its USB interface on macOS** (#511). The pure-Go USB backend enumerated a HackRF but failed to claim interface 0, returning `kIOReturnUnsupported` — `ClaimInterface` passed the device user-client type ID, but an interface service requires `kIOUSBInterfaceUserClientTypeID`. The interface path now uses the interface UUID (the device-open path keeps the device UUID). - **Live audio plays in Chrome via Web Audio** (#598). The "Tap to enable audio" button did nothing in Chrome on macOS: the hidden `