agentleFS
MattCheramieMattCheramie/GopherTrunk/CLAUDE.md

GopherTrunk

CLAUDE.md163 starsChanged 4 months agoApache-2.0View on GitHub

GopherTrunk is a Go SDR trunking scanner/decoder (P25, DMR, NXDN, TETRA, …). This file is standing guidance for AI-assisted work in this repo. Keep it short. Open issues are reviewed daily. Each pass surfaces new issues and issues with new responses since the previous day, and flags what needs work. Scope the review to issues updated since the prior day unless asked for a full sweep, and distinguish maintainer replies from genuine new reporter responses. - make vet test —…

We split this CLAUDE.md into the rules it gives an agent

7 rules. About 0 are common, 3 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Address the latest follow-up, not the original report: Never re-post the initial fix description as a close justification — respond to the …

    line 42
  2. Very specific:

    NO tx offset (uplink channel numbers already encode the uplink frequency).

    line 198
  3. Distinctive:

    Test-fixture lessons: an identical burst repeated for seconds carries a fixed symbol-mean bias no real transmission has, which the …

    line 696
  4. Distinctive:

    NOT two colour codes, NOT the tuner — the live wideband Tier II tap goes deaf in ~3-minute stretches, and the root cause is still open.

    line 715
  5. Very specific:

    ALWAYS returns the nearest RM(30,14) codeword (errs = Hamming distance, garbage lands at 4-6), so a faded AACH was a coin-flip …

    line 804
  6. Very specific:

    NOT RF, NOT post-processing.

    line 934
  7. Very specific:

    Watch the CPU angle: every /diag/symbols subscriber builds its own Downconverter from the FULL input rate plus a receiver …

    line 1041

≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

GopherTrunk — guidance for Claude

GopherTrunk is a Go SDR trunking scanner/decoder (P25, DMR, NXDN, TETRA, …). This file is standing guidance for AI-assisted work in this repo. Keep it short.

Daily issue review

Open issues are reviewed daily. Each pass surfaces new issues and issues with new responses since the previous day, and flags what needs work. Scope the review to issues updated since the prior day unless asked for a full sweep, and distinguish maintainer replies from genuine new reporter responses.

Build & test

  • make vet test — vet + unit tests; must be green before any commit.
  • make integration — daemon/replay integration tests; run when the daemon, DSP, or replay path changed.
  • Single package while iterating, e.g. go test ./internal/scanner/ccdecoder/....

Change scope (mirrors CONTRIBUTING.md)

  • Bug fix: one narrow commit plus a regression test that fails without the fix and passes with it. If you can't write a test that fails first, you have not yet reproduced the bug — keep digging or ask for a reproduction, don't guess at a fix.
  • Feature / refactor: design first; keep refactors out of behaviour-change PRs.

Issue-closing policy

Closing an issue is a claim that the reported problem is gone. Do not make that claim until it is verified. This policy exists because issue #764 was closed twice on an unverified fix while the symptom was still live (see #771); a PreToolUse hook (.claude/hooks/guard-issue-close.py) now asks for human confirmation before any close-as-completed.

  • Never close an issue as completed until the fix is verified: a failing-first regression test now passes and the reporter has confirmed it, or you have reproduced the original symptom and shown this change resolves it.
  • When you can't verify, leave it open. Post a concise status comment saying what you found and what's blocking (e.g. needs the reporter's capture files), rather than closing.
  • Address the latest follow-up, not the original report. Never re-post the initial fix description as a close justification — respond to the most recent comment specifically.
  • In PRs, prefer Refs #N over Closes #N until the fix is verified, so a merge doesn't auto-close an unverified issue.
  • Closing as not_planned or duplicate is fine and is not gated.

DSP / replay notes (so the next investigation starts ahead)

  • The Motorola P25 talker-alias cipher (#773) IS SOLVED and enabled (motorola.CipherVerified = true, PR #1123, v1.0.8) — do not tell a reporter it is still gated or still needs a chosen-plaintext capture. The 29 Sep stale-check bot did exactly that five weeks after the cipher landed, and the reporter gave up and moved to SDRTrunk. Verified: 1242/1242 held-out characters, the real #376 capture → "CRIO 0062" with a valid CRC-16/GSM, and an end-to-end dispatcher test on 144-bit sub-frames (sigfollow.TestDispatcherPublishesRealMotorolaAliasAsReliable). That test caught a live-only defect the 15-byte SDRTrunk-dump fixtures could never see: a live MAC PDU is 144 bits = a 16-byte vendor payload, and the fragment parsers sliced to the payload END, so each fragment carried one trailing byte that shifted the cipher region (RID parsed, name garbage, dropped). Fragments are fixed-length (64-bit header / 100-bit data, SDRTrunk's public constants). Lesson: when fixtures come from another decoder's dump, also pin the PDU shape the LIVE FEC chain hands out. What is NOT closed: on-air confirmation on the reporter's rig, and Phase 2 MAC yield on weak air (#915: 0/17 RIDs at mac_rs_valid=0 on their capture; the alias rides the same MAC PDUs). Operator guide: docs/talker-alias.md.
  • FLAC is now a first-class container on every recorder that had a container at all, with ONE shared stereo encode core (baseband.FLACIQEncoder — siglab.IQContainer delegates to it) and a mono voice twin (voice.FlacWriter): capture -format wav|flac and the API staged capture route through siglab.IQContainer (both used to silently write a mislabeled/garbage body — the streaming EncodeCapture has NO container case and falls through to u8, so gen/synth now reject wav/flac at the boundary); baseband.record[].format: flac covers both taps (wideband + ddc) and the replay FileDriver mounts .flac back as a virtual tuner (content-sniffed via the fLaC marker, never the extension); recordings.format: flac switches per-call voice recordings, and the WHOLE downstream chain reads either container via content sniffing (voice.ReadAudioSamples): loudness normalize rewrites flac-in/flac-out, broadcast MP3 transcode, the /calls/{id}/audio handler (audio/flac + .flac in its extension allowlist), the retention sweeper, and the web RecordingPlayer's download name. FlacWriter.DataBytes() reports UNCOMPRESSED PCM bytes so the recorder's duration/dead-key math is container-independent. diversity_capture now takes diversity_capture_format: flac too (operator space-saving request): the branch FLAC is a bit-exact twin of the cs16 (same clamp/scale, pinned by TestBranchRecorderFLACIsBitExactTwin) so the alignment invariant and every downstream conclusion are container-independent; the harness content-sniffs the branches, and rates > 1 MS/s fall back to cs16 (STREAMINFO ceiling + the encode runs on the stream goroutine). Not converted (deliberately): hunt -survey-capture stays f32.
  • A P25 "MBT data CRC failed" line whose identity fields match a decoded broadcast is TWO different PDU frames, not a contradiction: every field the failure line prints (opcode/blocks/nac) comes from the HEADER block, which carries its own CCITT-16 and decoded clean — only a data block failed its CRC-32 (RF residual errors; the broadcast repeats, so a clean copy usually logs nearby). The CRC span was re-verified byte-for-byte against OP25 process_PDU (single-block AMBT included), so do NOT chase a parser bug from that log pair alone; the line now carries the worst data-block Viterbi metric + an explanatory cause. Related naming rule now enforced in code: never render a vendor TSBK or an undecoded AMBT opcode through the standard Opcode.String() OSP map (it mislabels — MFID 0x90 opcode 0x00 reads GRP_V_CH_GRANT); ambtOpcodeLabel names only the decoded AMBT forms.
  • P25 opcode 0x03 (Group Voice Channel Update – Explicit) is NOT the 0x00 grant layout (#1242). It is svc opts | reserved | downlink chan | uplink chan | group (SDRTrunk bits 16-79, OP25 tk_p25.py) with no source unit; GT reused ParseGroupVoiceChannelGrant, read the channel from the reserved byte, and every explicit update on a UHF site resolved to the band plan's BASE frequency (450.000 MHz) with a garbage TG/src — calls then timed out on an empty carrier. Signature to recognise: frequency_hz == an IDEN_UP base_hz exactly. It had no test at all; pinned by opcodes_explicit_test.go (literal bit positions via tsbkBits).
  • A P25 TDMA grant's channel number counts LOGICAL channels (#1273): the IDEN_UP_TDMA (0x33) channel-type code carries slots per carrier (SDRTrunk ChannelType TYPE_0..5 = OP25 slots_per_carrier [1,1,1,2,4,2]; types 0-2 are FDMA), and the downlink is base + spacing × (n / slots), slot = n mod slots. GT ignored it — invented channel-type table, self-consistent with its own assembler — so every grant on a 2-slot Phase 2 carrier resolved to carrier n instead of n/2 (a hybrid P1 CC + P2 voice site never followed a call; the Mt Anakie "468.6125 MHz" pin was GT's own arithmetic). Fixed in phase1/identifier.go (SlotsPerCarrier, BandPlan.Timeslot → 1-based Grant.Timeslot), pinned by payloads built from OP25's bit positions. The Phase 2 CC had the same hole and worse: it parsed only the FDMA MAC form (0x7D), never the TDMA forms 0x73 / 0xF3 a Phase 2 site uses for its voice channels, so those grants resolved to 0 Hz — now parsed and divided (phase2.ParseIdentifierUpdateTDMA, also on IsKnown or strict/RSCorrect drops them). One channel-type table for both decoders: p25.ParseTDMAChannelType (phase2 cannot import phase1 — phase1's tests import phase2). The consumers that resolve an (id, number) pair from the TOPOLOGY snapshot (network report downlink fallback + explicit uplink, hunt.bandPlanFreq) used to compute carrier n because the snapshot carried only AccessTDMA; TopoBandPlanSlot.SlotsPerCarrier + ChannelHz now divide like BandPlan.Frequency, and p25_band_plan entries take slots_per_carrier (2/4 = TDMA, through phase1.IdentifierUpdateFromSeed, which also sets AccessTDMA so the grant routes Phase 2). The Phase 2 voice chain now records only the granted logical channel (see the air-format note below). A grant naming a never-announced channel ID now WARNs (rate-limited) instead of dropping at DEBUG. "p25: motorola opcode (site broadcast, not a call grant …)" lines (0x05/0x09; once "named, not field-decoded", which #1273's reporter read as undecoded grants) are broadcasts with constant payloads, logged once per distinct payload. On-air-gated.
  • The P25 Phase 2 TDMA framing in superframe.go / isch.go / voice.go was a WORKING MODEL that matched no reference — the voice path now runs on the real air format (phase2/airframe.go, #1273), and the PN44 scrambler had its taps mirrored. Real outbound air (OP25 p25p2_tdma.cc/p25p2_sync.cc/ p25p2_isch.cc/p25p2_duid.cc/apps/tdma/lfsr.py, SDRTrunk SuperFrameFragment/ISCHDecoder/ DataUnitID/Voice4Timeslot/ScramblingSequence — the two agree on every constant): 12 × 180-dibit timeslots per superframe, each = 20-dibit ISCH + 160-dibit body; the ISCH is the S-ISCH sync 0x575D57F7FF before timeslots 2,3,6,7,10,11 and a (40,9,16) I-ISCH (SDRTrunk generator ⊕ 0x184229d461 == OP25's 128-entry table) naming timeslot loc·4+chn before 0,1,4,5,8,9; the 8-bit DUID sits at body dibits 0/37/122/159; voice frames at body dibits 1/38/86/123 (VOICE-2: first two), XOR'd with PN44 bits [20+360t, 340+360t); the 72-bit AMBE+2 codeword is DMR's layout exactly (OP25 extract_vcw == dmrvoice rW..rZ). Logical channels are NOT slot parity: timeslots 10 and 11 swap ({0,1,0,1,0,1,0,1,0,1,1,0}, OP25 which_slot[] / SDRTrunk's final-fragment C/D swap); the grant's 1-based Timeslot (n mod 2, +1) names the channel. The old model had one sync per superframe, a Golay ISCH with an invented SlotType, contiguous unscrambled voice — self-consistent with its own encoder, and the #915 capture failed its ISCH on 46–62 % of sub-frames. framing.PN44Scrambler tapped bits 40/35/29/ 24/10/0 (the polynomial exponents) instead of 43/33/19/14/8/3 and diverged from both references at bit 49 (pinned now by a literal 4320-bit OP25 sequence). The MAC path is rebuilt too (macframe.go): SACCH / FACCH / LCCH (DUID 3·12 / 9·15 / 4·13) bodies are hexbits of a shortened, punctured RS(63,35) over GF(64) (framing.DecodeRS63_35, errors + erasures, = ezpwd RS<63,35> pinned by its literal output), then CRC-12 (FACCH 144 / SACCH 168 bits) or CRC-16 (LCCH, OP25 accepts crc16 == 0); a PDU's messages split by OP25's mac_msg_len table, and MAC_PTT / END_PTT are fixed layouts (encryption sync + source ride MAC_PTT). Five literal packets were checked crc_ok=1 by OP25's own decode compiled verbatim. Production consumers: composer (own logical channel only, clear LCCH always), sigfollow.DispatchAir (all slots), ControlChannel.ProcessAir (seed bootstraps from a clear-LCCH NSB — whose parser read the Color Code from the channel bytes and installed a wrong seed until this pass; 0xFB/0x7B now follow SDRTrunk). The old working-model MAC code (SuperframeDecoder MAC, trellis/RS(24,16)/ScramblerPin, flat Process) survives only for its own tests and groundtruth_replay_test.go; delete it in a separate PR. p25_phase2_{trellis,rs, interleave,scrambler}_mode are accepted but unread. The shared sync.Gardner applied its error with the wrong sign — the eye centre was unstable and it settled half a symbol off from any start (TestGardnerConvergesOnEyeCentre); Phase 2 runs Gardner by default, so every call's dibits decayed to ~50 % after ~16k symbols (~3 s at 8 sps). Its old tests used a triangle pulse + quadrant check that survive a half-symbol error — test a timing loop's convergence POINT on RRC data, not its symbol count. Synthetic + reference-pinned only; a Phase 2 capture is the on-air gate (#764/#771).
  • "Recording far shorter than the call" (#1242 follow-up) is usually the HOLD, not lost audio — read audio_pct before chasing decode. A call's duration is its wall-clock span (overs + gaps + hangtime); the WAV holds only decoded frames. Two defects made that look like loss: the sidecar's call_length / freqList.len were filled from the span (trunk-recorder's are the AUDIO length), and a voice_hangtime_ms longer than the engine's call_timeout_ms (default 30 s) never took effect — the composer touches the engine only while voice arrives, so the watchdog reaped the held call first (engineCallTimeout now raises it to hangtime + 5 s). Daemon slog lines (composer:, recorder:) go to STDERR, not messages.log/event_log — on Windows redirect 2> debug.log; under the in-process TUI they go to %TEMP%\gophertrunk-tui-*.log.
  • #1242 voice loss was IQ HOLES, not a weak signal — check frame-sync SPACING before any DSP theory. On the reporter's voice_iq_debug captures (wideband Pi, SoapyRemote 2.4 MS/s) consecutive P25 LDU frame syncs landed 790–850 symbols apart instead of the fixed 864: 2–3.5 % of every call's samples were missing, in 8–14 ms gaps that hit a third of the LDUs, while the eye was 12–25 % EVM (decodable) and the CC decoded fine. Cause: iqtap.Broker subscriber channels were 16 CHUNKS, ~60 ms for RTL-SDR 16 KiB chunks but ~2.4 ms for ~360-sample SoapyRemote datagrams — the depth-8 driver bug (streamBufferLatency) one layer down. Subscribers are now time-sized (SubBufferLatency 400 ms at the stream rate; pinned by broker_latency_test.go) and wbvoice WARNs its drop count per call (they were silent). An explicit chunk depth in iqtap.New keeps the old fixed behaviour. Confirmed on the reporter's Pi (v1.2.8, intelligible speech). Their Windows run (v1.3.1) still gave a whistle, and the voice path did not change between those builds. So the voice chain now measures sync spacing itself: the LDUAssembler re-anchors on a frame sync that arrives early (it used to swallow it, so a hole cost two LDUs) and counts it (FrameSlips). The per-call composer: p25p1 decode quality line carries short_ldus / missing_symbols, and a voice IQ has holes WARN fires at call end. Read that WARN first on any garbled P25 Phase 1 call: present ⇒ IQ lost upstream; absent ⇒ signal/demod.
  • P25 discovery: a PDU (DUID 0xC) on the control channel is Multi-Block Trunking, not noise — GT now decodes AMBT (mbt.go). The operator's "only 1 neighbor site, no WACN" report was this: their system broadcasts Network Status / Adjacent Status (with explicit downlink+uplink channels) only in AMBT form, and GT logged every one as non-control DUID duid=PDU and dropped it. MBT blocks reuse the TSBK 98-dibit trellis coding; the header ends in the same augmented CRC-CCITT16 as a TSBK trailer, the data blocks in a CRC-32 (OP25 process_PDU is the validation reference; SDRTrunk AMBTC* classes the field layouts). An explicit uplink channel resolves as plain base+spacing — NO tx offset (uplink channel numbers already encode the uplink frequency). Also fixed in the same pass: TSBK SCCB (0x39) read channel B one byte early (p[4:6] vs the correct p[5:7]) and its round-trip test passed because the assembler encoded the same wrong layout — pin parsers with LITERAL byte vectors cross-checked against an independent decoder, not just round-trips.
  • Never gate a per-channel health WARN on absolute dBFS when decode evidence exists. The widebandt2 "channel iq power very low" WARN fired every 5 s on a Tier III CC decoding every C_ALOHA at −56 dBFS. Any channel whose decode counter advanced within lowPowerDecodeGrace is healthy whatever its power gauge reads. Same family as the MRC dBFS-gate lesson below.
  • gophertrunk replay -tune-hz uses the single-channel ccdecoder.Downconverter (internal/scanner/ccdecoder/ddc.go), not the multi-tap wideband DDCBank (internal/dsp/tuner/ddc.go). They are separate paths — a fix to one does not touch the other. (This is what made the #764 "fix" miss the #771 replay symptom.)
  • Both down-converters normalise to the per-protocol channel rate (48 kHz for the 4800-baud C4FM family, 144 kHz for TETRA) and the receiver/AGC are sized from that output rate, so the decode path is rate-invariant to the capture rate. A symptom that only appears at a higher capture rate but reproduces in offline replay points at the captured data (front-end overload / intermod / gain staging), not the steady-state DSP — get the raw .cfile to reproduce. See internal/scanner/ccdecoder/ddc_highrate_test.go.
  • #764 is now verified against the reporter's own captures and confirms the rule above. Mt Anakie (−812.5 kHz) replays at demod SNR ≈19.7 dB / EVM 7.4% from the 2.5 MS/s capture (locks) but ≈9.5 dB / EVM 22.5% from the 10 MS/s capture (no lock). Decimating the 10 MS/s file 4:1 with an independent resampler and replaying it through the proven 2.5 MS/s path reproduces the SAME ≈9.5 dB — so the ~10 dB deficit is baked into the captured samples, not GT's DDC. Neither capture clips (both peak ≈−48 dBFS, so it is not overload/IMD), and the wideband FFT carrier SNR is actually higher at 10 MS/s — carrier-clean but modulation-degraded is the signature of front-end phase noise / reciprocal mixing at the Airspy's native 10 MS/s clock. TestDownconverterSNRInvariantAcrossRate in the file above pins this: a noisy channel reaches the receiver at the same in-channel SNR whether decoded natively at 10 MS/s or decimated to 2.5 MS/s.
  • TETRA voice decodes end-to-end: traffic burst → TCH/S channel decode (internal/radio/tetra/tch.go) → clean-room ACELP vocoder (internal/voice/acelp) → PCM. Two independent conformance passes against the ETSI EN 300 395-2 reference C codec underpin it, and both are reproducible via skip-guarded harnesses: internal/voice/acelp/etsi_reference_test.go (feed both the same 137-bit bitstream → bit-identical PCM) and cmd/gophertrunk/tetra_multislot_replay_test.go (replay a real cs16 IQ capture → per-slot audio, correlated against the control channel's grant timeslots). Build the ETSI tools with Word32 as a 32-bit int — on LP64 the default typedef long is 64-bit and every saturating op returns garbage. Lessons that cost time: the class-2 CRC is a fixed parity-check matrix (the reference's TAB_CRC tables), not a G(X) LFSR — the wrong CRC silently dropped every on-air burst while synthetic round-trips passed (self-consistent bug); and the SB anchors the slot grid one NDB-slot before its frame's TN1 traffic (ndbSBSlotShift in traffic.go). TCH/S now decodes soft-decision like the control SCH path (the receiver's SoftSink differentials → softType5FromDiffs → soft depuncture/Viterbi in framing.DecodeRCPCTetraMotherSoft → tetra.DecodeTCHSSoft); it was hard-decision until then, which failed ~70% of a marginal same-carrier call's bursts and produced short/garbled recordings. The traffic extractor carries the per-burst soft LLRs parallel to its dibit buffer (TrafficExtractor.StashSoft), and the composer falls back to the hard TCHSpeechFrames when no soft info is present. When "voice doesn't decode" but the vocoder unit tests pass, suspect the channel coding (CRC / interleave / reorder), not the vocoder — validate the whole chain against the reference, not just parts.
  • TETRA demod equalizer (internal/dsp/equalizer.SnapshotCMA, wired via the receiver's EnableEqualizer, enabled in the voice composer). On the reporter's concurrent-load captures the residual garble after soft-decision was linear channel / ISI (multipath / band-edge group delay) smearing the π/4-DQPSK constellation — not the signal-limited front-end degradation of #764. A blind CMA equalizer between symbol-timing recovery and the differential decoder inverts it and roughly doubles CRC-valid TCH/S yield across the six captures (soft-decision 410→778, ~1.9×; e.g. one call 4→207, another 42→134) with no loss on already-clean captures. SnapshotCMA is the differential-decoder-safe variant of the package's plain CMA (which applies its live, continuously- adapting taps — fine ahead of a coherent slicer, fatal ahead of a differential decoder). Lessons that cost time, all pinned by snapshot_cma_test.go + receiver_equalizer_test.go + the skip-guarded capture sweep:
    • CRC yield is the only trustworthy metric; EVM is a trap. Blind CMA minimises modulus, not correctness, and has spurious constant-modulus minima — a numerically-unstable variant once showed differential EVM collapsing 34%→8% while CRC stayed 0. Never conclude an equalizer helps from EVM; decode to CRC.
    • Never feed a continuously-adapting equalizer to a differential decoder. CMA's cost is rotation-invariant, so its output phase wanders as the taps adapt, and a time-varying phase does not cancel in s·conj(last) — every dibit is corrupted (streaming-adaptive → CRC 0, frozen taps → baseline). The design is adapt a tracking filter continuously but apply a FROZEN snapshot (refreshed every ≫-a-burst symbols) so each 255-symbol burst sees a constant filter; the one straddling symbol per snapshot is absorbed by the FEC.
    • Normalise by a constant (cumulative-mean), not a local EMA. The CMA update scales with |x|³; an EMA that tracks the TDMA downlink's slot-to-slot power swings gives a moving modulus target and CMA converges to garbage (CRC 0 even though a global-RMS normalise gives the full win). A divergence guard re-seeds the tracking filter to pass-through if a normalisation transient / deep fade blows the taps up, so one bad patch cannot poison later snapshots.
  • TETRA control-channel sync loss = RF degradation the CC equalizer now recovers, not compute starvation. A reporter's 1-hour session showed ~210 control_channel_transitions and 11 hard CC sync losses (auto-captured by on_cc_sync_loss). Each loss is preceded by bsch_fail climbing / sb_bursts collapsing, then repeated tetra: dsp resync (signal-time decode drought), then the 5 s stale watchdog → MarkLost → re-hunt (and a post-relock #815 carrier warning). Do not chase a compute fix: all 11 losses occurred with ~0 concurrent voice follows and the decode_overruns (704) were one bursty event — zero correlation with call/CPU load, so the signal-time resync design (immune to starvation) is correct and untouched. The captures are weak (peak −44 dBFS, no clipping, ~−3 kHz offset) and split cleanly by in-channel SNR: a healthy concurrent capture measures ~18 dB and replays at BSCH 147/0 (100%); a cc_sync_loss capture measures ~10 dB, LOCKS, but decodes only ~22% of its BSCH with a destructive-resync storm — the marginal regime that drops lock. The constellation is ISI-smeared, and the primary single-channel newTETRAPipeline was the one TETRA CC path not running SnapshotCMA (the voice composer and widebandt2/tetra.go already did; the latter's comment even claimed it mirrored the ccdecoder settings). Enabling it there lifts the marginal fixture from ~12% to ~100% CRC-clean BSCH — pinned by internal/scanner/ccdecoder/pipelines_tetra_equalizer_test.go against testdata/tetra_cc_sync_loss_2s_144k.cs16. EnableDCBlock stays off the CC path (voice-only per receiver.go). Gotcha: the equalizer's blind CMA is well-defined only against a noise floor, so the synthetic TestDaemonCCDecodesTETRA now adds 40 dB AWGN — a literally noise-free constant-modulus input is a degenerate case (same reason receiver_equalizer_test.go's clean-channel test adds 30 dB). The residual −44 dBFS weak front end is an RF/gain/antenna condition (the #764 lesson) the equalizer mitigates but does not replace — raise the signal level too.
  • TETRA training-sequence equalizer (equalizer.SnapshotLMS) is now wired into the per-burst traffic path (#1001 follow-up). The blind SnapshotCMA lives in the receiver (continuous, framing-free stream); the trained SnapshotLMS lives in the extractor (TrafficExtractor.EnableLMSEqualizer), because only the extractor knows where each burst's midamble sits. The linear channel is a clean convolution only in the raw symbol domain, not the differential domain (s·conj(prev) is a nonlinear product) — so the extractor carries the raw symbols down parallel to its dibit/diff buffers via the receiver's new SymbolSink → StashSymbols (the symbol analog of SoftSink → StashSoft). Per burst, softFrame trains on the known NTS1/NTS2 midamble from the raw symbols, freezes the taps, equalizes a BKN1..BKN2 span (with a taps-long FIR warm-up so the first BKN1 differential is past the transient), and re-derives the soft LLRs from the equalized symbols — hard frame untouched. It is soft-path only and opt-in: no StashSymbols/EnableLMSEqualizer ⇒ byte-identical to before. The reference is built by differentially encoding the ideal midamble from a unit anchor (arbitrary start phase; the constant rotation cancels in the differential decode, the same property that makes a frozen snapshot safe). Pinned by traffic_lms_test.go (synthetic multipath through the real extractor: raw 13% → 0% payload bit-error, no-harm on clean). Production composer still runs CMA only; flip LMS on there once the capture A/B validates it. A/B on captures with GT_TETRA_LMS=1 in TestTETRAMultiSlotReplay (compare traffic_marked_crc_soft).
  • The same training-sequence LMS lever is wired into the DMO (Direct Mode) path (#1003 follow-up). ExtractDMBurstsEqualized (dmo.go → dmo_equalizer.go) re-derives each rotation-0 DNB's SoftBKN1/SoftBKN2 from equalized raw symbols, so the existing DMBurstTCHSpeechSoft decodes it unchanged. Two DMO-specific points vs the TMO wiring: the DNB geometry differs (BKN1 [L-108,L), BKN2 [L+11,L+119), dmDNB*Start), and it only equalizes rotation-0 bursts — a frozen constant-tap filter cannot invert the per-symbol phase ramp of a non-zero residual rotation, and at rotation 0 the soft decode's (4-Rotation)&3 de-rotation is a no-op so the rotation-0-trained differentials slot straight in. Opt-in via ExtractDMBurstsEqualized (needs the SymbolSink); ExtractDMBursts/ExtractDMBurstsSoft are byte-identical. Pinned by dmo_equalizer_test.go (raw 12% → 0% DNB payload bit-error, no-harm on clean, byte-identical without symbols). A/B on captures with GT_TETRA_DMO_LMS=1 in TestTETRADMOReplay. Note the whole DMO path is still unverified on air (#1003), so this is a lever staged for that validation, not a confirmed win.
  • DMO on-air (#1003): the "encrypted" conclusion was WRONG — it was the colour-0 descramble skip, now fixed. The reporter confirmed their DMO radios are TEA0 (clear, no encryption), colour code 0 (CPS codeplug: DMO_438.900/.800, Security Class 1, NO_KG). That is the known-clear evidence the earlier note said was required, and it overturns the "air-interface encrypted" reading. What still holds from the first capture (tetra_dmo_test2_20sec_cs16_144k, 438.9 MHz cs16 at 144 kHz), pinned by TestTETRADMOReplay:
    • Sync + signalling fully decode. With the receiver blind-CMA equalizer (harness default — lifts CRC-valid SCH/S from 6 → 64 by inverting ISI, like the live TMO CC path), DecodeDMSCHS gives 64/68 SCH/S and DecodeDMSCHH 62/68. The DMO DM-SYNC PDU reuses the TMO SYNC-PDU field layout, so ParseSyncPDU decodes it (colour 0, TN, FN, MN, MCC/MNC=0) with a monotonically advancing frame counter — a genuine lock.
    • DNB geometry -108/+11 (dmDNB*Start) is CONFIRMED correct — the sharp TCH/S-CRC optimum; osmo-tetra-dmo's TMO-copied -115/+19 is measurably worse.
    • The real cause of "TCH/S doesn't decode": DMBurstTCHSpeech/DMBurstTCHSpeechSoft skipped descrambling at colour==0. TETRA scrambling is non-identity at colour 0 — NewScramblerTetra(0) seeds the LFSR to 0xC0000000 (§8.2.5.2 eq. 8.42), which is exactly why DecodeBSCH/DecodeDMSCHS always descramble and the DSB signalling decodes. The voice path inherited TMO traffic.go's if colour != 0 shortcut (safe there only because a TMO extended colour code is never 0) and so left a clear colour-0 DNB scrambled going into the Viterbi/CRC — producing the uniform ~1/256 chance-floor metric that was misread as encryption. The #1003 fix removes the guard on both DMO TCH/S paths (dmo_decode.go): descramble UNCONDITIONALLY with the DM colour code.
    • Why the earlier "with/without a colour-0 descramble" sweep didn't catch it: the synthetic round-trips scrambled and descrambled consistently at colour 0, so they passed either way and hid the asymmetry (the #764/#771 self-consistent-synthetic trap). dmo_decode_test.go now scrambles the encode side UNCONDITIONALLY (real-air behaviour), so the colour-0 iterations are the failing-first regression — they fail against the old skip and pass with the fix (verified: old code → DMBurstTCHSpeech returns 0 frames at colour 0; fixed → 2 CRC-valid frames).
    • Still to verify ON AIR (operator loop, not yet closed): a green synthetic ≠ on-air correct (#764/#771). The operator must replay their clear 438.9 MHz capture through TestTETRADMOReplay (GT_TETRA_DMO_IQ) and confirm tch_crc rises off the chance floor. GT_TETRA_DMO_CLEAR=1 flips the VERDICT line: on a capture asserted clear, a persistent chance floor is now a decode defect to keep chasing (next suspects: DNB geometry, the DM colour code via GT_TETRA_DMO_COLOUR), NOT encryption. Do not mark #1003 verified until that A/B lands.
    • The A/B landed on a 2nd on-air capture and CLEAR VOICE DECODES — the missing piece is the DM colour code, which is 3 here, NOT the 0 the signalling/CPS advertise. The operator's 10aug_dmo_test_bw144_cs16.raw (438.9 MHz DMO, cs16, replay with GT_TETRA_DMO_RATE=144000) locks signalling cleanly with the receiver CMA equalizer: dsb_schs_crc=44/45, distinct FN advancing. At the default colour 0 TCH/S sits at the chance floor (tch_crc=1/269), but a colour sweep is unambiguous: GT_TETRA_DMO_COLOUR=3 → tch_crc=35/269, speech_frames=70, 2.1 s PCM, voice-active seconds [1-8] (all other colours 0-15 stay at the floor; LMS GT_TETRA_DMO_LMS=1 doesn't help, 35→32). So the signal is NOT weak/encrypted (signalling decodes at the same RF) — it is a real clear-voice decode that only needs the correct DM colour code for the TCH/S descramble (DescrambleTetra(type5, colour) in dmo_decode.go).
    • Colour recovery LANDED (C1, capture-verified). The DM colour is NOT recoverable from the SCH/S (always colour-0-scrambled — reads 0). It IS in the DSB SCH/H (DM-SYNC SYSINFO, EN 300 396-3), but on a single capture colour 3 lights only the field's two LSBs so its exact bit offset is ambiguous — hardcoding it is the #764/#771 self-consistent trap (empirically confirmed: a colour-field scan found no unique 6-bit window). So instead tetra.RecoverDMColourCode(bursts) (dmo_decode.go) picks the colour (0..63) that maximises CRC-valid TCH/S — the correct one wins by a wide margin (measured 35 vs ≤3), with a confidence gate rejecting a chance-floor winner. TestTETRADMOReplay now auto-recovers the colour when GT_TETRA_DMO_COLOUR is UNSET: on the 10aug capture it recovers colour 3 → tch_crc=35, 70 speech frames, 2.1 s PCM, NO manual override. Pinned synthetic: TestRecoverDMColourCode. Reproduce: GT_TETRA_DMO_IQ=<10aug .raw> GT_TETRA_DMO_RATE=144000 GT_TETRA_DMO_CLEAR=1 go test ./cmd/gophertrunk -run TestTETRADMOReplay -v.
    • Production DMO decode pipeline is now WIRED (C2 done); only on-air A/B remains to close #1003. The offline decoders are now driven by a real daemon pipeline, so a DMO capture no longer runs the wrong TMO CC path (the operator's sch_pdus=0 / sync gap / dsp resync symptom). What landed:
      1. ProtocolTETRADMO (tetra-dmo/dmo) registered in internal/trunking/site.go (enum, String(), ParseProtocol, Validate) + internal/scanner/ccdecoder/ddc.go (ddcTargetForProtocol → 144 kHz) + the factories map in pipelines.go.
      2. newTETRADMOPipeline (internal/scanner/ccdecoder/pipelines_dmo.go): the TMO receiver knobs (EnableEqualizer blind CMA + SoftSink + AFC + channel filter) feeding a new tetra.DMStreamExtractor (internal/radio/tetra/dmo_stream.go) — a bounded sliding-window streaming adapter over the stateless ExtractDMBurstsSoft (emits each DSB/ DNB once, in lead order, deduped). Decodes DecodeDMSCHS → sticky KindCCLocked (no cc.lost on inter-PTT silence, so a camped DMO channel isn't re-hunted) + SYNC-PDU FN liveness, RecoverDMColourCode once, and an edge-triggered tetra-dmo KindGrant on a DNB traffic train (re-armed after a dmoGrantRearm drought).
      3. runTETRADMOVoiceChain (internal/voice/composer/tetra_dmo_voice.go): a self-contained same-carrier chain (one same-carrier tap — sameCarrierVoiceTaps in daemon.go) that re-runs the extractor → DMBurstTCHSpeechSoft (recovers the colour independently, since the grant fires before the pipeline's recovery, decoding its buffer retroactively so no leading speech is lost) → ACELP (tetra-dmo→tetra-acelp in recorder.go). Dispatched from composer.handleStart on proto == "tetra-dmo".
      • Pinned by pipelines_dmo_test.go (modulated-IQ lock+colour-3+grant), dmo_stream_test.go (chunk-invariance + soft decode), tetra_dmo_voice_test.go (colour recovery + retroactive emit), and integration_cc_tetra_dmo_test.go (full-daemon lock, make integration).
      1. FIRST on-air run found the grant path was driven by NOISE, now fixed. The operator's first live DMO run granted + opened a recording ~230 ms after startup on a silent channel, then never granted again — their real 10 s PTT (which did lock, dsb_schs_crc=46/54) produced nothing. One root cause: a raw DNB detection is not evidence of traffic. The DNB correlator is an 11-dibit training match at tolerance 2 under 8 filters (2 sequences × 4 rotations), so Σ_{k≤2} C(11,k)·3^k = 529 of 4^11 match by chance ⇒ ~1.0e-3 per dibit position ⇒ ~18 false DNBs/s at 18 kdibit/s. The operator's own log is the proof: dnb_total climbed 1076→4541 in 185 s (18.7/s) while dsb_schs_crc sat frozen at 46. dmoGrantMinDNB=4 therefore tripped in ~0.22 s, maybeGrant never checked p.locked despite the counter being named dnbSinceLock, and the 3 s re-arm drought could never elapse against a 55 ms mean inter-arrival — and was only evaluated inside the DNB branch, so on a truly silent channel it could not fire at all. Fix: tetra.DMSlotGrid (internal/radio/tetra/dmo_grid.go) votes DNB leads onto the 255-dibit timeslot grid — one radio on one clock puts every burst on one residue mod 255; noise is uniform over all
      2. The residue is learned, never hardcoded (a wrong spec-derived DSB→DNB lead offset would silently stop DMO granting, which is worse than over-granting), the latch is centred on the agreeing leads and tracks symbol slips, and it is dropped when the train ends (dmGridTrainGap) — without that, the ~0.2/s of noise landing on the latched residue keeps a finished transmission "alive" and the grant latches anyway. The grant now also requires p.locked, and the drought is evaluated from Process. Pinned failing-first by TestTETRADMOPipelineIgnoresIdleChannel / GrantsOnlyAfterLock / RearmsBetweenTransmissions (all three fail against the old code) plus dmo_grid_test.go. Note buildDMODibitStream now lays bursts on a true 255-dibit slot grid — the old arbitrary-filler layout was not a faithful transmitter and would not have caught this. Related, same run: the voice chain re-ran the 64-colour RecoverDMColourCode over its whole growing buffer on every burst (≈450k Viterbi decodes/call, 64·Σ(20..120)), which starved its own same-carrier IQ tap (dropped_chunks=5904); it is now capped at 6 passes over a bounded scoring window, while still keeping the full buffer so leading speech is still decoded retroactively. And flush() set colourKnown = true outside the confidence gate, so a call that decoded nothing logged colour=0 colour_known=true — read as "recovered colour 0" and sends you after the wrong thing; colourRecovered is now separate.
      3. STILL OPEN — on-air A/B (the #1003 gate). A green synthetic/offline decode ≠ on-air correct (#764/#771): the operator must replay a clear 438.9 MHz DMO capture through the full daemon (protocol: tetra-dmo) and confirm a recording lands with intelligible audio. Watch on air: the DMO grant carries no talkgroup (GroupID 0, EN 300 396-3 call-control not decoded), so recordings file under group 0; colour recovery needs ~20 DNBs, so a very short first PTT may grant before the colour is known (the voice chain re-recovers it); and the grant now lands ~0.5 s into a transmission (grid latch + 4 qualified DNBs) rather than instantly. dnb_qualified in the decode-status line is the number that means traffic — dnb_total is a noise meter, and a large gap between them is normal, not a fault.
      4. On-air A/B ran (15aug): the ceiling is RF/model, NOT colour recovery — and colour recovery is behaving CORRECTLY. The operator's purpose-built capture (dmo_test_15aug/25sec_ptt_then_off_30sec_cs16_144khz.raw, 25 s clear colour-0 silent PTT, 144 kHz cs16) decodes DSB SCH/S at ~90% (105/117) but TCH/S sits near the chance floor at every DM colour. TestTETRADMOColourScan (GT_TETRA_DMO_SCAN=1, the new diagnostic) sweeps all 64 colours: several rise modestly at once (28→140, 57→74, 30→46 of 831 DNBs) rather than one dominating — which one radio scrambling with one label cannot produce, and which is why RecoverDMColourCode's dominance gate (best ≥ 3×runner-up) correctly REFUSES to pick one (140 < 3×74). That refusal is the operator's "colour guessing problem": not a broken guesser but a marginal signal with no dominant colour. The SYNC PDU is self-consistent with MNI=0/colour=0 (extended colour 0, matching osmo-tetra-dmo's tetra_scramb_get_init offsets — GT's ParseSyncPDU bit offsets are identical), so the extended-colour path is not the gap here. The blind CMA equalizer (already on by default) lifts DSB 77→105 and TCH 80→140; LMS does not move it — so the receiver is already doing the right thing. NEXT: need a cleaner, KNOWN-colour (from the CPS codeplug), actually-TALKING DMO capture — the 25 s silent PTT is a poor vector (comfort-noise/DTX frames, and the sweep winners are partial keystream artifacts of a marginal signal). Do NOT change the descramble to fit a 33%-yield non-dominant colour (the #764/#771 self-consistency trap). The colour→colour_map is preserved in the diagnostic.
    1. The 15aug "no dominant colour" was the MNI-0 BLIND SPOT, root-caused: the reporter's radios run a NON-ZERO MNI (Motorola MTP8500Ex, MCC 250 / MNC 1), and GT's colour recovery only ever searched the 6-bit colour with MNI 0. TETRA seeds the scrambler from the FULL 30-bit extended colour code get_init(mcc,mnc,colour) (EN 300 392-2 §8.2.5.2 / osmo-tetra-dmo tetra_scramb_get_init; cross-checked against ctn008/TetraDMO-Receiver + lkurkela/osmo-tetra-dmo — GT's ParseSyncPDU offsets and ExtendedColourCode seed match osmo byte-for-byte). So on an MNI≠0 network the TCH/S traffic seed is ExtendedColourCode(250,1,colour), and RecoverDMColourCode's uint32(c) sweep over 0..63 (= MNI 0) can NEVER reach it — every candidate sits at the chance floor and several rise modestly with none dominant, which is EXACTLY the 15aug signature (the earlier note read it as "marginal signal / partial keystream", but the real cause was the wrong MNI in the seed). The DSB SCH/S is always colour-0 scrambled and carries MNI 0 on air (both refs agree; it is NOT a parse bug — reading MCC/MNC=0 from the SCH/S is correct), so the network MNI must come from config. Fix (RecoverDMColourCode(bursts, baseMNI) + tetra_mcc/tetra_mnc system config, threaded through the DMO pipeline → grant TETRADMOBaseMNI → voice chain): fold the configured MNI into every colour candidate (baseMNI | c) and the clear-fallback seed. Pinned failing-first by TestRecoverDMColourCodeNonZeroMNI (scramble with ExtendedColourCode(250,1,7) from the independent osmo formula → the MNI-0 search finds nothing, the MNI-folded search recovers the exact seed). STILL ON-AIR-GATED (#764/#771): synthetic ≠ on-air. The operator must A/B their 438.9 MHz 250/1 capture: GT_TETRA_DMO_IQ=<cap> GT_TETRA_DMO_RATE=144000 GT_TETRA_DMO_MCC=250 GT_TETRA_DMO_MNC=1 go test ./cmd/gophertrunk -run TestTETRADMOReplay -v (and -run TestTETRADMOColourScan with GT_TETRA_DMO_SCAN=1 + the same MCC/MNC to see whether a colour now dominates). If a colour dominates with MNI 250/1 where none did at MNI 0, that is the confirmation; if it still doesn't, the next suspect is the MNI value itself or DNB geometry — NOT encryption (the radios are TEA0).
    2. 20aug on-air run — "bogus DMO recordings that end at a timeout, not PTT release" is DIAGNOSED to the composer voice chain, NOT the control pipeline. The operator's dmo_test_20aug run (X310, MRC on / single antenna / no cavity — their own caveat: a poor vector) shows the two DMO decode paths disagree sharply, and the recording symptom is a second-order effect of the voice chain's colour handling — pinned by the run's debug.log:
      • The control pipeline (newTETRADMOPipeline) decodes fine: locks, RecoverDMColourCode → colour 39, and tch_crc climbs to ~200 CRC-valid TCH/S across one PTT window.
      • The separate composer voice chain (runTETRADMOVoiceChain) produced a silent recording for that same PTT: grant fired at colour_hint=0 (before the pipeline finished recovery), the chain buffered 242 DNBs, its buffer hit dmoVoiceColourMax=120 and the give-up path fell back to colour 0 (d.colour = d.baseMNI) BEFORE it ever adopted the pipeline's 39 — so all 242 DNBs decoded as BFI, speech_frames=0 colour_recovered=false. tryAdoptLiveColour never cleared afterward either: its local re-verification (≥2 CRC-valid at the hinted colour on the chain's OWN bursts) kept failing even though the pipeline's bursts decode at 39 — i.e. the voice chain's receiver is producing bursts the pipeline's is not. Prime suspect for the divergence: the voice chain runs EnableDCBlock: true while the CC pipeline runs it off (receiver.go / the CC-path note above) — try EnableDCBlock:false in tetra_dmo_voice.go and A/B.
      • Why it "ends at a timeout": zero real speech frames ⇒ boundaryTracker.onVoice is never called ⇒ the call is torn down by hangtime as reason=timeout (~7 s) instead of at PTT release. DMO decodes no explicit release PDU, so even a GOOD DMO call ends on hangtime — but a decoding call keeps refreshing liveness and runs to the real tail; a BFI-only call dies at the first hangtime. So the fix for BOTH the bogus audio and the early cut is the same: make the voice chain actually decode (adopt the pipeline's colour instead of falling back to 0 / re-verifying with a divergent receiver).
      • Not fixed this round (on-air-gated, #764/#771): the calls that started AFTER the pipeline knew the colour (colour_hint=39) yielded only 2–4 speech frames, but the pipeline decoded ~0 new tch_crc in those same windows too — weak for BOTH paths on this contaminated capture, so they don't isolate a voice-chain bug. Needs a clean, known-colour, actually-talking DMO capture (single antenna, MRC OFF, cavity filter) to A/B a voice-chain fix. The web-side symptom (a silent ~0.1 s WAV still published to History) is inherent: the recorder only drops dataBytes==0 or StatProvider zero-voice calls, and ACELP is neither, so a 2-frame call becomes a tiny bogus row. Staged, not shipped.
  • DMO (#1003) ROOT-CAUSED on the 12 Sep 3-PTT capture: the TCH/S scramble seed is PER TRANSMISSION — the transmitting radio's SOURCE ADDRESS under a fixed prefix — so every "colour code" ever recovered (3, 39, 36, 31) was a partial-keystream artifact, and no MNI/colour config could ever describe it. The instrument that settled it is an EXACT GF(2) seed solver (tetra.SolveTCHScrambleSeed, dmo_seed.go): the LFSR scrambler is affine in its 30-bit seed and the TCH/S coding (CRC parity matrix + K=5 conv code + puncturing + interleave) is a linear block code, so H'·(r ⊕ PN(0)) = (H'·P)·seed — 158 equations, 30 unknowns, one error-free DNB pins the seed with 128 redundant checks, no colour space, no field layout, no config. Solving the 12 Sep capture burst by burst (TestTETRADMOSeedScan, GT_TETRA_DMO_SEEDS=1) gave three seeds for three PTTs — 0x012915c0 / 0x015d9c07 / 0x01671384 — and each seed's low 24 bits sit verbatim at the DSB SCH/H bits 42..65, immediately before a 24-bit field at 66..89 that decodes as MCC 250 / MNC 1 (the operator's codeplug), i.e. the standard DMAC-SYNC layout … src addr type, SOURCE ADDRESS, MNI, message type …; the top 6 bits were 000001 on all three (capture-pinned, DMScrambleSeedPrefix; neither osmo-tetra-dmo nor TetraDMO-Receiver has any DMO traffic-seed rule at all, so there is no reference to quote). Lessons: (1) the earlier "MNI-fold" theory (tetra_mcc/tetra_mnc) was wrong — the 250/1 A/B decodes NOTHING — and the "colour 36 with 58 CRC-valid bursts" the brute force reported on this very capture was a wrong seed: a related wrong seed CRC-passes ~9% of bursts (measured), so a CRC count is NOT proof of a seed; only an exact solve is. (2) Production now runs tetra.DMSeedTracker in BOTH the pipeline and the voice chain: the DSB SCH/H announces the seed as a hint (adopted only after 3 CRC-valid decodes on slot-grid-qualified bursts — two was measurably too few against random payload), any burst's exact solve adopts immediately and preempts a stale seed (a stale seed from the previous PTT can still CRC-pass the next PTT's bursts, so the solve runs BEFORE the decode-at-known-seed), and errored bursts get a soft-assisted solve (least-reliable bit flips, then a reliability-ranked solve over LOCAL sparse parity checks — a bit error only spoils the ~48-bit windows containing it). Re-arm droughts reset the seed; a tetra_colour_code pins one. RecoverDMColourCode (the 64-way brute force) survives only for the diagnostic colour scan. (3) Verdict on the capture: 159 CRC-valid TCH/S across ALL THREE PTTs, speech in seconds [2-5, 13-16, 24-27], 9.5 s PCM (before: 58 of one PTT at a bogus colour). Reproduce: GT_TETRA_DMO_IQ=<flac> GT_TETRA_DMO_CLEAR=1 go test ./cmd/gophertrunk -run TestTETRADMOReplay -v (the harness now reads wav/flac). STILL ON-AIR-GATED for the daemon path (#764/#771): the operator must confirm a live recording with intelligible audio; the synthetic stream builders now open a transmission with THREE DSBs (as the capture shows) so the hint path is exercised realistically.
  • DMO (#1003) IS ON-AIR VERIFIED (13 Sep): five consecutive clear PTTs decoded live — the operator's "DMO is finally working" run, log + 60 s 144 kHz flac in sync. The seeds the live pipeline recovered (0x0001498b2a / 0x000162eab3 / 0x0001733855 / 0x00011f914e / 0x0001088472, one per PTT, all under the 000001 prefix — eight transmissions now) match the offline replay burst-for-burst. The residual complaint, "we are eating the first seconds of transmission, because of CC bruteforcing probably", is NOT the seed solve (it lands on the first grid-qualified DNB, ~1 ms) — it is three smaller things, each now measured by the new TestTETRADMOPipelineCaptureReplay (ccdecoder/, skip-guarded on GT_TETRA_DMO_IQ, replays a flac/cs16 capture through the PRODUCTION pipeline with a sample clock and prints per-PTT lock/seed/grant timing, CRC-valid bursts lost before the grant, a false-solve audit, and a cold-voice-chain-by-pre-roll sweep):
    • The grant waited for dmoGrantMinDNB=4 qualified DNBs even though the seed — an exact CRC-decoded solve — had already proved traffic on the first. Offline that cost the first 3 decodable bursts of every PTT (0.17–0.28 s); LIVE it cost 1.0 s and 1.8 s on two weak PTTs (18 qualified DNBs live vs 65 offline for the same PTT — the live grid latched slowly). maybeGrant(seedAdopted=true) now fires on the adoption; pinned by TestTETRADMOPipelineGrantsOnSeedAdoption (old: grant at #4, seed at #1).
    • The voice chain saw no IQ before the grant. Each PTT opens with a DSB-only setup phase (0.3–1.4 s of DSBs before the first DNB — protocol, not loss) and then 3–8 decodable DNBs BEFORE the 6-vote slot grid latches; a chain started cold at the grant can never decode those and loses more while its loops acquire. voiceFanout now keeps a 1 s pre-roll ring for DMO only (dmoVoicePrerollSeconds, set/cleared by the decoder per activation, delivered as the first chunk of SubscribeVoiceIQWithPreroll → CCVoiceSource.StreamIQ; the auto-record DDC tap keeps plain SubscribeVoiceIQ). Length is MEASURED, not guessed — cold receiver + extractor started at grant − pre-roll, CRC-valid bursts over the five PTTs: 0 s → 320, 0.5 s → 359, 1 s → 361, 2 s → 346, 3 s → 318. Longer LOSES: a receiver that starts on inter-transmission noise (blind CMA / cumulative-mean normaliser conditioning on the floor) decodes WORSE than one started at signal onset — one weak PTT went 60 → 20 bursts from 0 s to 3 s of pre-roll. That is also the likely reason the live warm pipeline (sitting in silence for minutes) decoded the weak PTT so much worse than the offline replay: receiver conditioning across silence → traffic onset is the next DMO lever, unmeasured beyond this sweep.
    • "composer: tetra DMO scramble seed changed … changed back" flip-flops (2 in 5 calls) were FALSE EXACT SOLVES from the soft-assisted reliable-check solver, never the dense solve: its ~48-bit local sparse checks overlap so heavily that the "24 redundant checks ⇒ 2^-24" claim was fiction — 7 of 1411 DNBs on this capture (6 noise, 1 real errored burst) solved to seeds that decode nothing. DMSeedTracker.ObserveDNB now adopts a seed-CHANGING solve only if the burst CRC-decodes at the solved seed (the decode it needed anyway, so free); a rejected solve is counted as solve_rejects in the status / ended lines and the burst decodes at the known seed instead (the real errored burst was thereby RESCUED: 2 frames at the true seed). Pinned by TestDMSeedTrackerRejectsSolveThatDoesNotDecode against two LITERAL capture bursts (tetra/testdata/dmo_13sep_false_solve_*.dnb) — old code adopts both.
    • Operator-side: their config had gain: 50 (= 5 dB, the startup WARN says so; the CC sat at −70 dBFS) — gain: 500 is the first thing to try before any further DSP work.
    • Still unverified on air: the pre-roll + grant-on-adoption pair in the live daemon (this round's changes were validated on the capture only; #764/#771). Also requested and done the same day: siglab: capture started/ended/aborted INFO lines in debug.log (serial, centre, rate, bandwidth, format, seconds, path; samples/recorded_seconds/elapsed at the end) so a capture lines up with the log without guessing.
  • DMR direct mode (#836) IS ON-AIR VERIFIED on the reporter's 15 Sep captures, and the captures exposed three more defects, all fixed the same day. Alvin's three 20 s cs16 captures (446.500 MHz centred, 2.4 MS/s, gain auto / 200 / 250, −1.6..−1.9 kHz offset, still 36–40 % ADC-clipped — the handheld is in the same room; it decodes anyway, so "clipped ⇒ undecodable by construction" was too strong) decode through the production Tier II path: lock, grant tg 99 / src 3024109 / cc 1, 34–39 superframes, 0 uncorrectable AMBE; the same files give ZERO sync words with NoCarrierGate (the #1186 pin on real air, TestDMRDirectModeRealAirKeyup). What the captures then showed: (1) every keyup lands at a random sub-symbol phase and the gated MM loop pulls a bad one in at gain·error per symbol — cold-started on PTT 2 at ten sub-sample offsets, 7 phases decoded the first burst and 3 took 1.3–2.8 s; live, the phase HELD through the gap from PTT 1 cost PTT 2 its entire ten-copy header train (1.5 s, no grant until late entry). Do NOT read MMClockMu() as a drift meter: mu is a per-symbol countdown, its value at a chunk boundary is the loop's phase, and a slow walk is the loop converging. Fix: feed-forward acquisition at every onset after an absence

    100 ms (receiver/timing_acq.go → sync.EstimateSymbolPhase → SetPhase). The Oerder-Meyr square-law line was tried first and is USELESS here (20 % roll-off: the symbol-rate line is often below the noise floor); the kurtosis eye-opening search (central moments — an uncorrected offset biases raw moments) is exact to ±1 sample ≥ 95 % of 96-symbol windows and validated burst-by-burst on the capture (phase 0 at every header copy, drifting ~0.6 samples/s through the PTT = TX-vs-RX clock, jumping 3 samples at the next keyup). Acquisition arms only AFTER an absence so the continuous-carrier "gate is a no-op" pin stays byte-identical. (2) This radio repeats its Voice LC Header TEN times over 0.6 s; the 0.25 s re-key rule measured from the FIRST copy ⇒ phantom release + re-grant on every keyup; the anchor now follows the last copy (TestConventionalHeaderTrainIsOneKeyup). (3) Direct-mode voice must be sliced at the 288-dibit cadence (one burst per 60 ms frame): the single-slot 132-cadence decoder slices the gaps — its ambe_ok counts are BOGUS (muted gaps decode as valid all-zero Golay words) and lc_superframes=0 — so trunking.DMRVoiceCadenceDetected now defaults every DMR protocol (Tier I included; siglab and replay -record-voice had no System at all) to the cadence-detecting decoder; embedded LC then decodes in ~80 % of superframes and late entry works. The DC spur of a channel-centred capture was ruled out (−28 dB, DC-block changes nothing). Still open: the reporter's LIVE run on a build with these fixes (#764/#771), and TestDMRIPSCReplay still needs GT_DMR_INTERLEAVED=1 for a direct-mode file (it now says so).

  • DMR direct mode / simplex (#836) NEVER decoded, and every earlier round chased the wrong thing (ppm, gain-too-low, "odd rotation") — the receiver was blinded by the GAPS between bursts. A handheld on a simplex frequency transmits one 27.5 ms burst per 60 ms frame (one TDMA slot) and is off for 32.5 ms; every synthetic DMR fixture laid bursts back-to-back like a repeater, so the self-consistent trap hid it for the issue's whole life. In the gap the FM discriminator of receiver noise is uniform over ±π — several TIMES the signal's ±0.25 rad/sample — and that inflated the symbol AGC's 53 ms level EMA (every outer symbol then sliced as inner ⇒ no sync word, all ±3, ever matched), decayed the post-clock AFC, halved the coarse acquirer's window mean (a 1.2 kHz offset read ~550 Hz, at its own deadband) and random-walked the MM loop. Failing-first: the production Tier I AND Tier II pipelines decoded ZERO sync words from a 27 dB-SNR direct-mode stream (pipelines_dmr_directmode_test.go, receiver_burst_test.go). Fix = dmrrx.carrierGate: the discriminator's running mean-removed VARIANCE (measured on the reporter's capture through the production DDC: 0.01–0.08 rad² inside bursts even when ADC-clipped, 2.3–3.9 rad² in gaps / band-limited noise — a 30× separation, offset-independent, no dBFS anywhere) with hysteresis (open <1.0, close ≥2.5) and a 4-symbol delay line so the decision aligns with the burst edge; it HOLDS the AGC / AFC / acquirer / MM-error updates on absent samples (gated ProcessGated variants, byte-identical when all-present) and MUTES the discriminator there (the RRC matched-filter memory otherwise carries ±π gap noise into every burst's first span — half the headers failed BPTC with the trackers gated but the discriminator unmuted). Two more traps found on the way: (1) the acquirer applied its correction "on the NEXT chunk" — an RTL-sized 4096-sample chunk is a whole burst, so decode after engage depended on where the chunk boundary fell (34/40 vs 20/40 syncs); it now re-mixes the engage chunk at the frozen offset from a clean history. (2) clock.Reset() on engage THREW AWAY a good timing lock: a de-rotation moves nothing in the timing domain, and the gated loop crawled back for over a second (129/132 → ~80/132 per burst); it now re-locks only at |offset| ≥ coarseAcqClockRelockHz (1400 Hz, the #1165 notch where the sgn() error term is genuinely broken). Consequence for operators: with an uncorrected offset above ~1 kHz the FIRST PTT after start-up trains the acquirer (~0.5 s of bursts) and later PTTs decode from burst one — set ppm from capture's measured line to get the first one too. Test-fixture lessons: an identical burst repeated for seconds carries a fixed symbol-mean bias no real transmission has, which the open-loop AFC reads as carrier drift and shifts the eye by ~0.16 d (the #402 class) — model a PTT as 3 headers + random voice bursts; and the modulator's RRC group delay is span·sps samples, so gate synthetic bursts on the DELAYED grid or you chop their tails. The reporter's 14 Sep capture itself is UNDECODABLE by construction — 40 % of raw samples at the ADC rail (gain: auto, handheld in the same room; 30 ms on / 30 ms off at +1.8 dBFS) — which capture, replay's verdict and the wideband no-sync WARN now all say explicitly instead of "no frame sync was found" / "set sdr.ppm". STILL ON-AIR-GATED (#764/#771): the direct-mode fix is synthetic-verified only; the reporter must re-capture with a fixed gain (~200, stepping down until the overload WARN stops) and replay / run live.
  • make test-integration / make integration had NO -timeout, so a slow hosted runner fails a green PR. PR #1175's integration job died with panic: test timed out after 10m0s inside TestSweepImplementationLossBudget (P25 Phase 1 receiver, 21 s into a test that is seconds on main) because go test's 10 m default is a PER-PACKAGE alarm and the package as a whole crossed it under -race on that runner — the same lesson ci.yml's build-test job already applied (-timeout 25m, run 2394). Both Makefile targets now carry -timeout 25m (landed on main via PR #1182, which also merged main into #1175). When a PR shows only the integration job red with that panic, re-run before reading it as a defect.
  • 12 Sep IPSC "missed a lot of calls" (442.3875 MHz, 20-min Signal Lab flac + live log): NOT two colour codes, NOT the tuner — the live wideband Tier II tap goes deaf in ~3-minute stretches, and the root cause is still open. Facts pinned: every one of the 26 865 data bursts in the capture is colour code 12 (the EMB colour code of all 544 embedded LCs too, all tg 11), so the reporter's "IPSC runs two colour codes, one per slot" is not what this repeater does (the 9 Sep cc=7 CSBK train was on the OTHER repeater, 443.2375). Offline, the same capture decodes every one of its 26 transmissions (37 grants, TestDMRIPSCReplay interleaved; TestDMRIPSCBurstDump is the burst-level instrument), and so do the channelizer arm of TestDMRIPSCWidebandReplay over the whole 20 min AND with live-sized chunks (GT_DMR_WB_CHUNK=500). Live, aligned to the capture (offset 23:41:31.28), the Fire2 tap logged sync_hits=0 for six stretches of 180–210 s (23:38:24, 23:42:58, 23:47:46, 23:52:07, 23:56:34, 00:00:35 — alive ~60–110 s between) while its IQ power sat at its normal −51 dBFS and the Fire tap on the same channelizer decoded throughout; ~10 of the 26 transmissions fell in those stretches (that is the "missed calls"). The coarse carrier acquirer (the one frozen stage) never engages on the capture, the MM loop/AGC/AFC have no long time constants, chunking is not it, nothing in the daemon runs on a ~3-min timer, and an activity line cannot distinguish "no dibits" from "junk dibits" — so the tier2 counters now carry dibits, and the engine self-heals: healDeafTier2 resets the receiver + tier2.ResyncReset() after 3 windows with no sync at a power within 6 dB of the level the channel LAST SYNCED at (its own level, never an absolute dBFS — the tap sits at −51 dBFS, below the −45 "strong signal" hint floor, which is why no WARN ever fired), logging mm_mu/mm_sps/agc_level/coarse_offset_hz at the reset as the instrument for the next log; deaf_heals in the activity line counts them. Pinned by engine_deafheal_test.go. dmrrx.Receiver.Reset used to leave the timing loop and demod history in place — a reset that is not a reset — now cleared. Do not re-chase the channelizer bin edge (the 10 Sep fix holds: polyphase 37 grants vs DDC 36 over the full capture); the next field log's dibits + heal WARN internals decide whether the latch is in the receiver (heal works, internals show which loop) or upstream of it (heal does not work ⇒ the tap's NCO/resampler or the stream).
  • TETRA MAC fragment reassembly had off-by-bits at every seam — found via D-NWRK-BROADCAST, and the neighbour-cell decode is now live + capture-verified. macFragmentPayload skipped only type+subtype on MAC-FRAG (the fill-bit indication leaked into the payload, +1 bit) and fill+length on MAC-END (the slot-granting + channel-allocation flags leaked, +2 or more) — the round-trip test was green because its encoder shared the wrong layout (the #764/#771 self-consistent trap, again), single-block PDUs were unaffected, and the corruption surfaced only in fragmented L3 PDUs from the seam onward. Diagnosed on the operator's 120 s 467.9125 MHz MRC capture: a D-NWRK-BROADCAST rotating neighbour list decoded ~2 cells then garbage; deleting exactly ONE bit at the seam made all seven advertised cells decode — the raw MAC-FRAG blocks confirm the payload starts one bit later (layouts pinned against osmo-tetra rx_macfrag/rx_macend). ParseDNwrkBroadcast (mle_parse.go, layout cross-checked osmo-tetra + tetra-kit) now feeds TopologySnapshot.Neighbors (Site=5-bit cell id, StatusFlags carries sync+mcc/mnc/la) → the systems report's "Neighbor sites"; cells surface only after the SAME content decodes twice — the 6-bit PD+type gate lets a rare corrupted-but-CRC-passing TL-SDU parse plausibly (one-shot surfaced 18 "neighbours"; the repeating 8 were real: carriers 467.99-470.00 MHz, LAs 1021-1089, TestTETRANeighbourReportReplay is the skip-guarded harness). Follow-up (Sep 3): the operator's "totally bogus entries in the neighbours list, like a 1.5 GHz one" — confirmed-twice garbage — was TWO MORE MAC boundary holes, both deterministic (so identical corruption repeats and the confirm-twice gate cannot help): (1) tmSDU/macFragmentPayload handed everything to the BLOCK end — the MAC length indication (total PDU octets; osmo rx_resrc macpdu_length*8, tetra-kit decodeLength(li)*8 - pos) was parsed but never applied and the fill-bit indication never stripped fill ('1' then '0's, §23.4.3.2) — so fill/multiplexed tail bits leaked into the TL-SDU and D-NWRK-BROADCAST's trailing P-bit reads turned them into phantom optionals (band bits 1111 ⇒ a 1.5 GHz "neighbour"); prefix-reading parsers never noticed, which is why everything else looked fine. (2) fragment reassembly had NO continuity check — a MAC-END spliced onto however-old a start fragment across lost blocks; now pieces must be stream-adjacent (fragMaxGapDibits, the NCDB detector's dibit position plumbed through decodeDownlinkSlot) and an AACH-confirmed control slot that decodes nothing abandons the chain (osmo-sq5bpf ages fragslots the same way). Test builders used to write a PLACEHOLDER length indication the decoder ignored — honouring the field made them encode real lengths (stampMACResourceLength), the encoder/decoder-drift trap in yet another dress. plausibleNeighbourCell (carrier≠0, band 1..9) is defence-in-depth behind those fixes, and the neighbour log/StatusFlags now print MCC/MNC/LA (and the newly surfaced §18.5.17 status optionals — raw values; their spec bit maps stay un-named until capture-confirmed, per the CommsType rule) only when present, so "mcc=0" can no longer mean "absent". Related dedup in the same pass: retransmitted D-RELEASE/D-DISCONNECT now publish ONE call.release per teardown (releaseSeen, re-armed by a fresh grant) — the "release spam" report, same family as grantSeen/lastTalker.
  • An implausible D-NWRK-BROADCAST cell now distrusts the WHOLE broadcast, not just itself (4 Sep field report: "one bogus neighbor site"). The list is parsed sequentially, so a physically-impossible cell (band 13, carrier 0, …) proves the bit alignment was lost — and its plausible-LOOKING siblings from the same bits are garbage too. The operator's log showed exactly that: a "cell 0, carrier 80, synced" phantom 65 MHz off-network (the web UI's 402.0125 MHz neighbour) confirming twice ALONGSIDE a band-13 implausible sibling in the same broadcasts at 02:11/02:31/02:33 — deterministic corruption repeats bit-identically, so confirm-twice can never catch the siblings. learnNeighbourCells now drops the entire list when any cell fails plausibleNeighbourCell and logs the raw TL-SDU hex (tl_sdu_hex) so the NEXT such report can pin the actual mis-framed layout without a capture (the corruption source — likely another MAC boundary/seam hole in a rarely-sent broadcast variant — is still un-root-caused; the hex dump is the instrument). Pinned failing-first by TestLearnNeighbourCellsRejectsImplausibleCells (old code reproduces the operator's exact row).
  • The phantom-neighbour corruption source is ROOT-CAUSED (5 Sep, from the 4 Sep tl_sdu_hex instrument alone): mis-continued fragment reassembly SPLICING TWO TRANSMISSIONS of the rotating D-NWRK-BROADCAST. The 11 rejected dumps in the operator's 10.4 h log decode as the real cell list (9,10,11 … 57-bit cells, perfect) that mid-cell jumps back to another copy of the list — identical substrings repeat inside one TL-SDU, and every dump left 15–388 trailing bits after a "complete" cell list (genuine: <8, the MAC bounds the TM-SDU to the PDU's own length). Bogus sites that LOOKED plausible (mnc=1021/1032 = the real cells' LAs shifted into the MNC field) were the same splices confirming twice, because the loss pattern repeats with the broadcast schedule. Three holes let a chain survive the losses that set a splice up, all fixed in decodeDownlinkSlot/fragChainAdjacentLocked: (1) a control slot with ONE decoded SCH/HD half counted as recovered — the failed half IS a lost signalling block (on a control slot both halves carry signalling; stealing exists only on traffic slots); (2) DecodeAACH is an ML search that ALWAYS returns the nearest RM(30,14) codeword (errs = Hamming distance, garbage lands at 4-6), so a faded AACH was a coin-flip classification and "traffic" kept the chain — the classification is now trusted only at errs ≤ aachClassifyMaxErrs (2). (Searching the other three rotations on unconfident slots was tried and REVERTED: most emits on this capture are unconfident, and the extra 16k-codeword ML searches took the 120 s replay 61→139 s, past real time — the multi-rotation loop was always unreachable anyway, r=0 is the stream's rotation); (3) adjacency tightened 4→2 frames + the continuation must land on the chain's 255-dibit slot grid. The grid gate is load-bearing, measured on the 4 Sep MRC captures: the NCDB detector also emits spurious off-grid correlator hits (+92/+163 dibits, tolerance-2 matches inside payloads), and a naive "gap between emitted slots" check (first attempt) aborted every chain on this SCBS/timeshare carrier (neighbours 0/8) — integrity evidence must be slots on the chain's own grid, nothing else. Defence in depth: ParseDNwrkBroadcast rejects ≥8 trailing bits after the neighbour list (kills the whole splice class even if a new hole appears; reject logged with tl_sdu_hex), and neighbours now EXPIRE after neighbourExpiry (30 min) without re-advertisement — aged only across ACCEPTED broadcasts, so a CC outage expires nothing — ending the 10-hour phantom pile-up. The periodic "abandoning TM-SDU fragment reassembly" DEBUG line is the continuity guard WORKING (each abandon costs one broadcast cycle; the reporter read it as a parse bug) — it now says so, and frag_abandons in the decode-status line tracks the rate. Pinned failing-first: TestParseDNwrkBroadcastRejectsSplicedFieldCapture (a LITERAL field dump), half-slot / unclassifiable-AACH / on-grid tests in frag_continuity_test.go, expiry in mle_parse_test.go; no-harm: both 4 Sep captures replay 8/8 real cells through TestTETRANeighbourReportReplay.
  • TETRA control-plane decode surface (the "advanced D-SYSINFO / D-ATTACH/DETACH / D-NEW-CELL / SCCH" request) — what's decoded vs deliberately raw. ParseSysInfoExtended (sysinfo_ext.go) decodes the rest of the 124-bit SYSINFO: common-SCCH count (+ TS2..TS4 mapping), MS_TXPWR_MAX_CELL / RXLEV_ACCESS_MIN / ACCESS_PARAMETER / RADIO_DOWNLINK_TIMEOUT, hyperframe-vs-CCK id (osmo's flag sense: flag SET ⇒ CCK id), and the D-MLE-SYSINFO TM-SDU at the FIXED offset 82 (= 124−42: LA + subscriber class + BS service details) — layout pinned by osmo macpdu_decode_sysinfo AND tetra-kit pduProcessSysinfo; the change-gated INFO log excludes the hyperframe counter or it spams every cycle. MM D-ATTACH/DETACH GROUP IDENTITY (mm_parse.go, layout from tetra-kit mm.cc/mm_elements.cc — the type-3/4 element walk skips unknown elements by their OWN 11-bit length so OTAR/security elements can't desync it) yields an ISSI→GSSI feed: attaches publish KindAffiliation (protocol tetra), detaches log. MLE D-RESTORE-ACK wraps a CMCE SDU (5-bit type onward, tetra-kit's forwarding) → decoded via ParseCMCERestoreAck so a mid-call cell re-selection keeps call state. D-NEW-CELL / D-PREPARE-FAIL / D-RESTORE-FAIL / D-CHANNEL-RESPONSE / D-NWRK-BROADCAST-EXTENSION are recognised and logged with raw TL-SDU hex but NOT field-parsed — NEITHER reference decoder parses their contents, so a field layout would be the fabricated-SmartNet trap; the hex log is the capture-pinning instrument. CORRECTION of an earlier note here: tetra-kit DOES itemise the 12 BS-service-details bits (parseBsServiceDetails), so they are now rendered as named flags (BSServiceDetailsString) in SYSINFO logs and neighbour StatusFlags; subscriber class and timeshare stay raw (still no decoder itemises those).
  • The in-call event payloads (call.source, call.talker, call.release, call.segment) now carry snake_case JSON tags + frequency_hz so the web activity feed's one formatter renders them like grant rows (they used to marshal Go-capitalized names, which the SPA's snake_case-only summarizeEvent silently rendered as an empty detail cell — the "call.talker shows nothing" report; same silent-mismatch family as the History r.rows lesson). Still passthrough kinds (docs/api-events.md notes the rename); summarizeEvent also descends into a nested grant/Grant object for call.start/end/complete.
  • Competitive gap series (Oct 2026) — the subsystems it added and the rules they follow (docs/competitive-feature-assessment.md is the catalog + open-gap list):
    • P25 OFB-family decryption (phase1/voicecrypt.go): DES-OFB / TDES / AES share ADP's frame mapping with a one-block discard (8 / 16 bytes vs RC4's 256); the AES IV is MI ‖ LFSR64(MI) (p25crypto.ExpandMI, = OP25 expand_mi_to_128), NOT a left-justified MI (the cryptolab had that wrong). Reference-pinned, no GT capture yet — TestP25ADPReplay with a DES/AES key is the on-air gate. encryption_keys.algorithm normalises to rc4 | des | tdes | aes; the key LENGTH picks the variant and the chain refuses a key whose length disagrees with the ES's ALGID rather than guessing.
    • recordings.mute_encrypted mutes PCM only (WAV + live tap); .raw/.imb/.amb keep ciphertext. "Decryptable" is decided by RecorderOptions.KeyConfigured (daemon builds it from encryption_keys; key id 0 = any key on the system), never by whether the composer actually decrypted — keep it that way or a failed decrypt would mute speech.
    • Alerts (internal/alerts): rules × channels; channels are plain HTTP except exec (argv split on whitespace, JSON on stdin + GT_ALERT_*) and mqtt (a 150-line QoS-0 MQTT 3.1.1 publisher — do NOT add a client dependency for it). Delivery runs on a bounded queue; a dead destination must never block the bus. New bus kinds need: an alerts.Normalize case, a defaultMessage case, config.AlertRuleEventKinds, and a CC Activity label if operator-facing.
    • Hold / avoid (trunking/holdavoid.go) sit in HandleGrant after Lockout and before the scan-list gate; emergency bypasses both; a held TG bypasses list mode; neither persists (a restart clears them like a scanner power cycle — deliberate).
    • P25 unit signalling (phase1/opcodes_unit.go): parsers are pinned by LITERAL vectors built from SDRTrunk's bit indexes with an encoder independent of the Assemble* inverses (tsbkBits in the test) — keep new TSBK parsers to that pattern.
    • Transcription (internal/transcribe): one OpenAI-shaped multipart client covers OpenAI / whisper.cpp / faster-whisper / LocalAI; uploads 16 kHz mono WAV by default (whisper.cpp refuses anything else). Transcripts attach to call rows THROUGH call_recordings by path (segments append) — the recorder's CallComplete.AudioPath is the key, so never rename a recording between CallComplete and the store.
    • Conventional lockouts persist by FREQUENCY (storage.ConvLockoutStore, table conv_lockouts, wired through conventional.Options.LockedOutHz / OnLockoutChange in cmd/gophertrunk/conv_lockouts.go) — never by list index, which shifts when the operator edits the scan list. Talkgroup hold / avoid stay session-only on purpose. scanner.priority_interleave is the conventional priority scan: pickNextChannel visits the next Priority-bearing channel after N ordinary picks, and a cursor visit to a priority channel resets that clock (pinned by lockout_persist_test.go).
    • Adding a config section touches: config.Config, sectionValidators(), configbuilder/fieldmeta.go (every field needs Help — TestFieldHelpCoverage), configbuilder/sections.go, web/configbuilder/src/api/types.ts (every field — TestConfigSchemaCoveredByWebBuilder), a web/configbuilder/src/sections/*.tsx + index.tsx entry, config.example.yaml, and a docs/*.md with nav_group: Reference.
  • Every config key must appear in config.example.yaml when it lands — diversity_capture_format shipped in code but was missed there, and an operator guessing the key name at their rig is the failure mode (4 Sep report). When adding a Config field, grep config.example.yaml before committing.
  • The "sausages" in the operator's TETRA waterfall are the BS toggling discontinuous-downlink (timeshare/MCCH-sharing) mode per multiframe — not RF trouble and not a GT defect. On both 1-2 Sep 120 s MRC captures the CC's occupied bandwidth alternates ~±10 kHz ↔ ~±12.3 kHz in sharp ~1.02 s (= one multiframe) segments while total in-channel power stays constant (±0.3 dB) and branch fades are uncorrelated (r≈-0.06); slot-boundary ramp dips (14.2 ms) are always present, and the +2.4 kHz "tone" is the SB frequency-correction sequence, both normal. GT decodes straight through the segmented windows (BSCH ~99.9%, all harness arms at ceiling) — same family as the reporter's #925 SCBS/dynamic-MCCH-sharing observations. Don't chase a receiver bug from a "sausage" waterfall alone; check decode yield first.
  • TETRA individual/private-call SRC is restored across mid-call PDUs via a callID→source binding; cold-start group-vs-individual classification stays capture-gated. ETSI compresses the SSIs out of mid-call and traffic-channel signalling (§14): once a call is set up, a D-CONNECT or a follow-on MAC-RESOURCE addressed only by call identifier carries NO party element, so the source went blank after the setup burst (the reporter's "missing SRC on private calls"). Fix mirrors the existing callGroups (callID→GSSI) map with callSrc (callID→calling/ transmitting party ISSI), learned from D-SETUP's calling party and D-TX-GRANTED's transmitting party, dropped on release; classifyParties backfills a blank grant source from it (guarded against a dest==src self-source), and it flows to trunking.Grant.SourceID (the grant-dedup snapshot already keys on src, so a grant that GAINS a source re-publishes). Pinned by TestCallSourceRestoredMidCall. What is NOT done, and why: correctly flagging the FIRST PDU of an unseen private call as individual (vs surfacing the called ISSI as a phantom talkgroup) needs a single-PDU group/individual discriminator. Three independent decoders (sq5bpf, tetra-kit, Wireshark) confirm the D-SETUP layout and that the discriminator is the 2-bit communication type sub-field of Basic Service Information (bits 29..30 — GT now decodes it via readBasicService into CMCEMessage.CommsType, alongside circuit mode + the service encryption flag). BUT no decoder NAMES the enum values, and none of them classify from it — they defer to the downstream ecosystem (telive), primarily on temporary-address presence. So the mapping (0=p2p, 1=p2mp, 2=p2mp-ack, 3=broadcast) is spec-derived and capture-UNCONFIRMED. Per #764/#771 it is NOT wired into classifyParties; instead the value is logged on D-SETUP (comms_type_raw in tetra: d-setup basic service, debug) so an operator's KNOWN individual-vs-group capture can confirm the split empirically. Only then does CommsType earn a place in classification — the named Comms* constants are staged for that one-line wiring. Downlink D-SETUP carries NO called-party element (only U-SETUP uplink does), confirming the dest must come from the MAC address, as GT does.
  • Vocoder "sounds awful" is a MEASURED, LOCALIZED AMBE+2 3600×2450 (DMR) high-band deficit — NOT RF, NOT post-processing. Using the operator's DSD-FME (mbelib) decode of the SAME .amb frames as ground truth (err=[0], identical frame count), GopherTrunk's ambe2-dmr decode has 4–10× less energy above 1 kHz: octave band fractions (0-300/300-1k/1-2k/2-3k/3-4k) are mbelib 0.200/0.513/0.181/0.073/0.033 vs GT-raw-ambe2-dmr 0.405/0.540/0.041/0.008/0.005. Content matches (envelope corr 0.69 — same speech, crushed highs). Isolation: the base ambe2 (3600×2400) decoder shares the exact same synthesis + §6.2 EnhanceAmplitudes and gives mbelib-like highs (0.121/0.053/0.020), so the deficit is purely in unpackParams2450 (internal/voice/ambe2/params2450.go + tables2450.go) — the 3600×2450 spectral-amplitude quantization reconstruction produces systematically low high-l amplitudes. NOT fixed this round: this is conformance-pinned table code, so the fix needs a frame-by-frame diff against a wired mbelib 2450 reference (an objective band-energy regression target), not a speculative table change. Config note: the operator's warm_dmr_audio: true + enhance LPF only make it worse (they cut highs further); the synthesis is the cause. Reproduce: gophertrunk decode -in <dmr>.raw -out gt.wav -vocoder ambe2-dmr and compare octave-band fractions to the DSD-FME decode of the paired .amb. IMBE (P25) shows the same direction, milder.
  • DSD-FME .imb/.amb interop VERIFIED (frame-exact); the "slow/pitch" playback is an upstream DSD-FME quirk. recordings.mbe_files writes DSD-FME's native cookie-headed container and DSD-FME decodes it cleanly (err=[0]). DSD-FME's -w single.wav writer stamps an 8 kHz header on 12 kHz synthesis (file plays ~1.5× slow) — envelope correlation is 0.69 after a 1.5× time correction, proving content parity. Not a GT bug; use DSD-FME -P (per-call) or -o pulse, or relabel the -w WAV to 12 kHz. Documented in docs/vocoders.md.
  • Conventional DMR (Tier II / IPSC) now decodes a repeater's TWO timeslots as two calls. A base-station DMR repeater carries TS1 and TS2 interleaved on one carrier, each able to hold a separate simultaneous talkgroup. The conventional path (internal/radio/dmr/tier2) used to collapse both: two symptoms — "DJ scratchy" audio (both slots' AMBE frames sliced into one superframe) and two talkgroups ping-ponging into one call. Root cause was NOT the DSP: (1) the composer picks single-slot vs interleaved decode from Grant.DMRInterleavedVoice, which Tier III stamped but Tier II never did — dmr_interleaved_voice: true in config was computed (resolveDMRInterleavedVoice, daemon.go) but dropped on the floor because tier2.Options had no such field; (2) grants carried Timeslot 0 with scalar inCall/lastTG/lastSrc state, so the engine's (freq, timeslot) identity folded both slots. Fix wires InterleavedVoice through tier2.Options + both constructors (ccdecoder/pipelines.go, widebandt2/engine.go; Tier I direct mode stays single-slot), and replaces the scalar state with a per-destination map that assigns each concurrent call a synthetic Timeslot (1/2) — an engine-identity token only; the base-station wire format does NOT label a burst's physical slot (both slots share the BS sync words and the slot-type field carries only colour+datatype), and the composer's slotRouter routes audio by the embedded-LC talkgroup, not by this field. The Terminator-with-LC destination is now decoded (RS seed RS129SeedTerminatorLC) so a TS1 terminator releases only its own call; a lone active call still tears down promptly on an undecodable terminator, but with two concurrent calls an undecodable terminator defers to per-chain hangtime rather than cross-tear the other slot. Concurrent two-slot recording rides the pre-existing dmrSameCarrierTaps = 2 taps (distinct serials → no composer per-serial collision). Pinned by conventional_twoslot_test.go (failing-first). NOT yet on-air-verified (#764/#771 discipline): the operator's only IQ grab (dmr_ipsc_60sec_bw25k_cs16.raw, 25 kHz cs16) is a dead capture — ~−75 dBFS RMS, carrier at −11.35 kHz, no frame sync — so the two-slot audio A/B still needs a decodable capture (a wideband .raw, or a properly-gained single-channel one). The synthetic regressions + the operator's own debug.log (interleaved never reaching the decoder; per-superframe log spam) corroborate the bug. Sharp edge to watch on air: if the embedded LC never decodes (#644), both same-carrier taps' slotRouters fall back to phase parity and could bind the same phase (one slot recorded twice).
  • Conventional DMR "false call ended" (9 Sep IPSC report) was a FORGED TERMINATOR from a voice burst, root-caused on the operator's own captures; the missed continuation was the absence of late entry. The Tier II slicer (tier2/process.go) parsed a slot type on EVERY sync match, but only DATA bursts carry one — a voice burst A has AMBE bits in the slot-type positions, and Golay(20,8) decodes ~1/3 of arbitrary 20-bit words to SOME codeword, so ~1 voice burst A in 12 read as (cc, TerminatorWithLC) and the single-call fallback ("LC undecodable but only one call active ⇒ unambiguous") ended the live call mid-sentence. Measured on dmr_tests_9sep (25 kS/s cs16 SigLab slices, 442.3875 MHz, tg 11): a terminator exactly 6 bursts (1728 dibits) before the next voice superframe of the SAME over, and 11 grants for 5 transmissions. Fixes, all in internal/radio/dmr/tier2 and pinned failing-first by conventional_lateentry_test.go: (1) slot types are read only from data-sync bursts — per polarity, because DMR's data/voice sync words are each other's PolarityFlip image (dmr.SyncIsDataAtPolarity), and the stream's polarity is LOCKED by the first FEC-valid burst so the voice-sync gate becomes exact; (2) the undecodable-terminator fallback now requires a BPTC-valid payload (a repeater repeats the real Terminator-with-LC for its whole hang time — 50–170 copies on these captures — so a genuine end never hinges on one uncorrectable burst); (3) late entry: the channel now runs the voice superframe assembler beside the burst slicer and grants from two agreeing CRC-valid embedded LCs (ingestVoiceSuperframe, ~720 ms in) when a transmission's Voice LC Headers were lost — what every subscriber radio does, and why the operator's radio "still heard the conversation" (their weak −60 dBFS bin-edge tap lost headers at keyup; the composer only starts on a grant). Gates that cost time: a late-entry LC whose superframe STARTED BEFORE the destination's last terminator is the closing superframe of the ended call (the assembler runs a span behind the slicer) — without endedAtDibit it re-granted every dead call (7 phantoms/120 s); and the IPSC color_code filter is honoured via the superframe's majority EMB colour code (VoiceSuperframe.EMBColorCode). Capture-verified with TestDMRIPSCReplay: GT_DMR_DROP_HEADERS=1 scrubs every header burst from the real dibit stream and all 5/5 transmissions are still granted (late_entries=5); un-scrubbed runs grant exactly once per over; capture 1's mid-PTT start is late-entered. The synthetic Tier I fixtures (siglab/fixtures.go, integration_cc_dmr_tier1_test.go) framed a Voice LC Header with the DM VOICE sync and only passed because of the old parse-everything slicer — a data burst uses the data sync (ETSI TS 102 361-1 Table 9.1); fixed. The "CSBK CRC mismatch" storm is NOT resolved: the operator's 443.2375 MHz log shows a 30 ms-cadence (both slots) train of BPTC-clean, CRC-failing CSBKs at cc=7 for ~4 s on a cc=12 system — a real proprietary train, not noise (the CRC convention itself is pinned by real Tier III vectors). The Debug line is now parked (first + summary per 10 s, csbk_crc_fail in the activity line) and carries csbko/fid/lb/pf + info_hex, the instrument for the next log; a capture of that frequency idle is what pins it. The CSBK/FEC failures that DID appear in these captures (7/120 s) were the same voice-burst forgery and are gone.
  • DMR group calls are no longer relabeled "individual." The engine's known-radio → individual reclassification (HandleGrant, internal/trunking/engine.go) and noteRadio's talkgroup retraction rest on a TETRA-only invariant (GSSIs and ISSIs never overlap). DMR shares one 24-bit space for talkgroup and radio IDs, so ungated it flipped DMR group calls whose TG equalled some radio ID — mis-filing recordings under individual/<TG>/ and corrupting the individual column that backs the Radio IDs roster (its LastTalkgroup SQL excludes individual=1 rows). The whole mechanism is now scoped to g.Protocol == "tetra"; DMR's FLCO classification is authoritative. Pinned by engine_dmr_classify_test.go.
  • The web symbol panels pick their receiver from the SYSTEM's protocol, not the P25 demod mode. An operator on a TETRA rig saw a permanent symbol: poor badge next to a correct decode: clean. The two chips are deliberately different axes (frame-error rate vs raw constellation), but this was not that: p25ModulationFor (cmd/gophertrunk/spectrum_provider.go) only inspects ProtocolP25 systems, so a TETRA/DMO/DMR-only config reported "", the web demodModeToProto("") fell back to p25-c4fm, and the panels opened a P25 C4FM receiver on a π/4-DQPSK carrier. Its 4-level soft track is meaningless there but non-empty, so computeQuality took the MER branch, measured ~9 dB, and qualityVerdict bucketed <10 dB as "poor" for ever. (Tell-tale: the Histogram panel with Mode manually set to TETRA showed SNR (MER) — and balance ±5.7%, which grades clean — two panels running different receivers on the same carrier.) Fix: symbolProtoFor + SpectrumDevice.SymbolProto (symbol_proto) resolve the actual /diag/symbols selector per protocol, and the web autoProtoFor prefers it. This also removes a whole wrong receiver per open panel. Watch the CPU angle: every /diag/symbols subscriber builds its own Downconverter from the FULL input rate plus a receiver (internal/scanner/symbolscope/scope.go), nothing is pooled, and Dashboard + Scanner + Plots each mount useSignalQuality while Histogram opens a fourth. Several open tabs = several full DSP chains. Pooling them is still open work.
  • "Spectrum waterfall works on the example config, blank after saving my own" (Discord, Oct 2026) = no decoder was streaming the SDR, NOT the API mutation/auth settings. An iqtap broker only fans IQ to subscribers while a PRIMARY StreamIQ session runs; the example config's example P25 system makes the CC decoder stream the control SDR, so a config with trunking.systems: [] (what the config builder emits before systems are added) left every live view — Spectrum, scopes, mixer, Signal Lab capture — on a silent subscription. Views now call Daemon.acquireViewerIQ (viewer_iq.go), which drives StreamIQ itself while a view is open on an SDR with no primary, and a single-channel decoder claiming the serial later preempts it (openSingleChannelIQ). It deliberately skips role: voice SDRs (the composer streams those through the raw pool device, OUTSIDE the broker — a viewer stream would collide with the next call) and the conventional scanner's SDR. The broker's Streaming is now a session COUNT, not a flag (an old session ending no longer reports a new one stopped). The Spectrum panel gained a typed MHz tune entry (an untuned SDR sits at 0 Hz, out of click-to-tune reach) and a hint when an open stream delivers nothing.
  • soapyremote: SDR overruns … host_drops is a DOWNSTREAM signal, not a driver bug. sendOrDrop (internal/sdr/soapyremote/driver.go) only sheds when the consumer stops draining a ~400 ms / ~1084-chunk channel, and it drops the OLDEST queued chunk, so each event is an IQ discontinuity mid-queue. internal/sdr/soapyremote/ has not been modified since the repo import, so when this fires, look at what got slower on the decode side (the DMO colour brute force above was one such cause) — and check for the companion ccdecoder: decode can't keep up with real time WARN, which confirms CPU rather than network.
  • sdr.input_sample_rate is a systemwide pre-decimation stage at the Device boundary. When set (and an exact integer multiple of sdr.sample_rate), the hardware runs at the higher NATIVE rate and internal/sdr/decimate.Device integer-decimates (polyphase anti-alias FIR, dsp.NewResampler(1, M, …)) down to sample_rate BEFORE anything downstream — DDC bank, demods, recording taps, baseband.auto_record/iqtap, spectrum. It is wired via Pool.WrapDevice (sdrInputDecimator in daemon.go) so it applies in OpenWith AND survives Reacquire; the wrapper programs the inner device at sample_rate*M and streams the M:1 result, so sample_rate stays the DECODE rate every existing cfg.SDR.SampleRate reader sees (zero downstream audit — the whole reason it wraps at the Device boundary rather than threading a native/effective split through the daemon). Two things it deliberately does NOT touch: the pre-combine diversity_capture tap lives INSIDE the soapyremote driver, below the wrapper, so it still records native branches (correct — a diversity A/B needs native); and it does NOT fix front-end degradation baked into a high native-rate capture (#764 — decimating 10→2.5 MS/s does not recover the Airspy's native-clock phase noise). It is a load/recording-size lever, not an RF fix. Pinned by internal/sdr/decimate/decimate_test.go (rate math, ActualSampleRate÷M, anti-alias rejection).
  • P25 Phase 1 weak-signal voice is the under-equipped decode path — diagnosed, NOT yet fixed (needs a capture). An operator whose hardware Astro Spectra decodes a marginal P25 Phase 1 voice call cleanly gets only ~4-5 IMBE frames from GT on the same antenna. Root cause is structural, not a bug: the default C4FM voice receiver (internal/radio/p25/phase1/receiver/receiver.go, C4FM branch) is FM-discriminator → fixed matched filter → CoarseAFC → Mueller-Müller timing → AGC → 4-level slicer → hard Golay/Hamming IMBE FEC — with no channel equalizer and no soft-decision FEC. A blind CMA/FSE equalizer exists only on the opt-in DemodCQPSK/LSM path (cqpsk.go, the fse field), and P25 Phase 2 wires one too — P25 P1 C4FM is the odd path without either. These are the same two levers that ~2×'d TETRA yield on ISI/weak captures (SnapshotCMA + soft TCH/S). ("4-5 frames" also brushes minAutotuneLDUs=5 in composer/p25p1_voice.go, which treats a <5-LDU call as too short to trust.) The fix is unverifiable without a capture (#764/#771): drop a raw C4FM Phase-1 voice IQ recording into samples/p25/ (+ .metadata.json), baseline it with TestReplayP25RealCaptureMetrics (cmd/gophertrunk/p25_realcapture_metrics_test.go, tag integration; pre-FEC EVM/SNR/ FSW-margin/LDU yield), then either port the cqpsk.go CMA/FSE equalizer onto the C4FM path or add soft-decision to the IMBE FEC, and A/B LDU/IMBE yield against the capture. No change lands without that capture. See samples/p25/README.md (weak-signal voice section).
  • The Motorola Type II / SmartNet framing was FABRICATED and never matched the air interface — rebuilt from OP25/trunk-recorder (#1143), on-air verification still pending. The original package (24-bit sync 0xA4D7AA, 32-bit OSW, BCH(64,16,11)) matched no real reference; every synthetic test was green because encoder and decoder shared the invented format (the #764/#771 self-consistent trap — same as the SoapyRemote opcode bug below), while no real capture could ever lock (cchunt: hunt failed, the reporter's symptom). The real format, ported from OP25 rx_smartnet.cc/h + trunk-recorder SmartnetParser (both proven on air): 8-bit sync 0xAC, 84-bit frames back-to-back (a frame is only trusted when the NEXT sync arrives 76 bits later), 76-bit payload → stride-19 deinterleave → (info,parity) pairs with parity[i]=info[i]^info[i-1] → 27 data + 10 CRC bits, data INVERTED on the wire (address ^0xCC38, command ^0x0D5, CRC complemented); OSW = 16-bit address + group bit + 10-bit command, where a command ≤ the band plan's range IS the voice channel number and grants/sysID span 1-3 consecutive OSWs (no opcodes — motorola_bch_mode is now accepted-but-ignored, motorola_band_plan selects 800_standard/800_rebanded/800_splinter/900). Physical layer: 3600-baud 2-FSK at ±1.2 kHz deviation (NOT MSK/±900 Hz), channelized to an 18 kHz DDC target (5 sps, mirrors trunk-recorder; the old 48 kHz target's ±24 kHz passband also admitted 25 kHz-spaced neighbours into the discriminator), with a slow post-discriminator DC tracker — at ±1.2 kHz deviation a few-hundred-Hz carrier offset is a large slicer bias (TestReceiverToleratesCarrierOffset). Pinned by reference-literal tests (sync bits, interleave permutation, XOR masks — the only tests that catch constant drift) + failing-first TestProcessDecodesRealAirFormat (real-air stream → old decoder = zero decodes). Per #764/#771: synthetic-green ≠ on-air-verified — the #1143 reporter's 854.5625 MHz capture (Airspy R2, 3 MS/s cfile on Google Drive; unreachable from the dev environment's network policy) is the outstanding verification gate. Their "≈550.3 ppm" capture warning was almost certainly the probe estimator latching a different momentarily-strong carrier in the 3 MHz span (the probe is the FIRST 32768 samples ≈ 11 ms, and capture never checks ActualSampleRate — though sample-count math shows their file really is 3 MS/s).
  • The YSF FICH that internal/radio/ysf/fich.go + fich_trellis.go modelled was FABRICATED — both files are now DELETED and the voice chain AND the control channel decode via airframe.go (#1261). The old model convolves 48 info bits into "100 on-air bits"; on air the FICH is 100 DIBITS (200 bits): a 20×5 interleave, a rate-1/2 K=5 code (g1 = d⊕d3⊕d4, g2 = d⊕d1⊕d2⊕d4), four Golay(24,12) words (MMDVMHost's CGolay24128 — NOT framing.GolayEncode24_12, which is a different generator; 4094/4096 codewords differ), 32 info bits + CRC-16/GSM. Its encoder and decoder shared the invented layout (the self-consistent trap again), and the control channel's live Process never fed it anyway — a YSF CC locked on the FSW but never granted. ControlChannel.Process now runs an AirFramer: header/comms FICH (CM≠3 individual, DT≠data FR) grants on the DG-ID (comms = late entry), terminator clears; pinned failing-first by TestControlChannelGrantsFromOnAirFrames. The lock still follows the bare FSW (siglab's FSW-only fixtures rely on it). DecodeAirFICH is pinned by five on-air vectors from MMDVMHost's own encoder, and V/D mode 2 voice (DecodeVD2Voice: 5 VCHs × 104 bits, 26×4 interleave, 13-byte whitening, 27 triplicated + 22 plain bits = the DMR 49-bit AMBE+2 payload) by YSF2DMR's silence pair: YSF_SILENCE through this package and DMR_SILENCE through the DMR AMBE decoder give the SAME 49 bits. Test-fixture trap that cost a round: YSF is RRC-shaped (α 0.20) like DMR, so fixtures use demod.ModulateC4FM; ModulateP25C4FM gives ~45 % dibit agreement through the YSF receiver and looks like a receiver bug. Only V/D mode 2 is decoded; V/D mode 1 and Voice FR are logged. Unverified on air. The YSF and D-STAR voice receivers had NO carrier correction and NO channel filter (#1261): measured on synthetic streams, D-STAR decoded nothing from ~150 Hz of carrier offset and YSF failed from ~200 Hz. Both now run EnableAFC (CoarseAFC on the discriminator, ahead of the timing loop) + EnableChannelFilter (±4 kHz / ±6.25 kHz) via newDStarVoiceReceiver / newYSFVoiceReceiver; either fix alone still decodes 0 of a 400 Hz-off transmission with a same-power neighbour (dstar_ysf_offset_test.go). The CC paths are unchanged.
  • The LoRa bit layer was FABRICATED (#586) and is now the Semtech air format. Whitening, CRC, header, Hamming layout, interleaver and the chip mapping were all invented, and the modulator shared them, so every round trip passed while a real frame's header checksum failed 100 % (verified: old code decodes 0/6 reference frames). Real format (whitening.go, header.go, fec.go, frame.go): whitening LFSR advances ONE bit per byte (0xFF,0xFE,0xFC…); CRC = CRC-16/0x1021/init 0 over all but the last two bytes, XOR those two (BE), appended unwhitened low nibble first; 5-nibble header (len hi/lo, CR<<1|crc, 5-bit checksum) opening a REDUCED-RATE 4/8 first block of SF-2 rows that also carries SF-7 payload nibbles; Hamming with the nibble bit-reversed in the top bits; interleave sym[i] bit j = cw[(i-j-1) mod rows] bit i (MSB-first); chip = GrayDecode(sym)(×4 if reduced) + 1. RX rounds the reduced-rate /4 (gr floors). Pinned by bitexact_test.go against gr-lora_sdr AND jkadbear/LoRaPHY, transcribed separately and identical on 2916 random cases (they differ only in padding-codeword content). Chirp/sync/SFD already matched. On-air-gated (#764/#771): no real LoRa IQ capture has been decoded yet.
  • The NXDN frame layer in internal/radio/nxdn (frame.go / sync.go / lich.go / the CAC decoder) is FABRICATED — only airframe.go is the real air format, and only the voice path uses it (#1261). The invented model has a 16-bit FSW 0xC55A, a 32-dibit SACCH and no scrambler; real NXDN (MMDVMHost NXDNDefines/NXDNLICH/NXDNControl SCRAMBLER, DSD-FME nxdn_frame.c/nxdn_deperm.c) is FSW 10 dibits (0xCDF59) | LICH 8 (bit in each dibit's MSB, parity = XOR of the top 4 bits) | SACCH 30 | Info 144, every post-FSW dibit inverted by PN9 (x^9+x^5+1, seed 0xE4) — a symbol inversion is an MSB flip in GT's dibit map. The 72-bit NXDN AMBE+2 frame is DMR's verbatim (DSD-FME nW..nZ == dmr rW..rZ), so DecodeAirVoice calls dmrvoice.DecodeAMBEFrame; GT's old NXDN AMBE layout was an admitted placeholder. Conventional voice rides RDCH (RFCT 2), not just RTCH — the old gate checked RTCH only. NXDN48 is the same 4-FSK frame at 2400 sym/s (receiver.Options.SymbolRateHz), NOT a 2-level BFSK mode as the receiver once claimed. The voice receiver runs EnableAFC (the scrambled frame has no constant-symbol runs): both reporter captures sat ~400 Hz off, which decodes NOTHING at NXDN48's ±350 Hz inner deviation without it. Pinned by realair_test.go on two 48 kHz slices of the reporter's Kenwood captures (NXDN96: 36 voice frames, continuity 0.86; NXDN48: 56, 0.82; the old code decodes 0) plus a literal MMDVMHost scrambler-table test. The voice receiver also runs a per-mode channel filter (EnableChannelFilter: ±6.25 kHz NXDN96, ±3.5 kHz NXDN48) — without it the 2.4 MS/s front end hands the discriminator ±24 kHz and a same-power neighbour channel decoded 0/12 frames (nxdn_voice_channelfilter_test.go). narrowband_filter is a HackRF-Pro-only switch: on an RTL-SDR it is a no-op + WARN, so an operator report that it "fixed" a decode on an RTL is coincidence — look for the real cause. STILL OPEN: the trunked CC (CAC) path is on the invented layout and cannot frame real air — rebuild it on airframe.go against a CC capture; and the daemon path on air (#764/#771). That CC capture now exists (20 Oct, 453.775 MHz, NXDN96 RCCH): 213 frames in 8.7 s frame at the real FSW, 192 dibits apart, every LICH RFCT 0 with good parity; a 1 s slice pins it (TestAirFramerFramesRealNXDNControlChannel). So the "CAC has long constant-dibit runs" claim in the receiver's EnableAFC comment is from the invented layout: real post-FSW dibits are all PN9-scrambled. "Works 5-10 min, then fails until ppm ±1" (20 Oct) was the timing loop seeing the carrier offset: the NXDN receiver's AFC is post-clock, so NXDN48 decoded only within ~±700 Hz (0-12/56 frames at ±750-1250 Hz, then back further out — non-monotonic, filter-independent) and NXDN96 ~±1.5 kHz. The same CC capture showed the dongle drifting −645 → −679 Hz in 8.7 s right after a ppm calibration. EnableCarrierTracking (voice only) is a pre-discriminator FLL on the discriminator mean, gated on discriminator variance < 0.25× filtered noise (without the gate it random-walked to 1.1 kHz on noise: no restoring force in its own mixed frame), run on tc/4 sub-blocks so the result is chunk-size-independent; ±3 kHz now decodes. The DMR receiver was swept the same way on its real-air keyup and holds sync over ±2 kHz (its coarse acquirer is pre-discriminator already), so DMR drift is not this. The CAC is now decoded on real air (air_cac.go, 21 Oct): 214/214 frames of that capture pass the CRC. Layout (DSD-FME facts, MMDVMHost code): 150 CAC dibits after the LICH → 12×25 deinterleave ((i%25)*12+i/25) → 12-of-14 depuncture (positions 3, 11 dropped) → framing.ViterbiK5 (175 stages) → 171 bits = SR (structure 2 + RAN 6) | 147-bit L3 message | CRC-16, checked by a shift register seeded 0xC3EE over all 171 bits (remainder ^0xFFFF == 0). Message type = L3 octet 0 & 0x3F (0x18 SITE_INFO, 0x19 SRV_INFO, 0x04/0x05 VCALL_ASSGN). Cross-check that settled the field positions: SITE_INFO location 0x400AA1 = Local, system 85, site 1, and every SR carries RAN 1 = site mod 64; SITE_INFO also names control channel 1 and 2 adjacent sites, as the reporter describes the system. ControlChannel.Process runs the real framer (airFramer, shared with TrafficChannel) beside the legacy invented paths; the siglab NXDN synth/FEC tally and the daemon integration fixture moved to the real format (EncodeAirControlFrame, pinned to an independent MMDVMHost-derived encoder). The CC pipelines run EnableCarrierTracking (0 CAC frames without it at the capture's −650 Hz) but NOT the channel filter: on the siglab wideband survey it turned DMR leakage into a false NXDN 0.43 carrier. VCALL_ASSGN is reference-pinned only (the capture is idle); a capture with a call on it is the gate.
  • Airspy Mini + Pi 4 "one thread at 90 %, dropping live IQ chunks" with 2 TETRA cells at 3 MS/s (#1321) was the DDC bank, not the real→IQ converter — and the pump benchmark is the instrument. TestEngineDualTETRA200kThroughput now takes GT_WB_BENCH_RATE; at 3 MS/s it measured 0.39× real time on a 2.8 GHz Xeon with 96 % in DDCBank.Process: the shared decimator's floor was 2.5 MS/s (ddcDecimateFloorHz, #764), so below 5 MS/s NOTHING was pre-decimated and each 144 kHz TETRA tap resampled the full 3 MS/s with a 250-tap-per-output polyphase (12·M/L taps), plus Resampler.Process walked all L commutator phases per input sample (as costly as the filter). Fixes: (1) NewDDCBankForPlan(…, maxAbsOffset, maxTapRateHz) lowers the floor to (maxAbsOffset + maxTapRate/2)/sharedDecimPassFrac (0.30 — the shared stage's Kaiser β7, 12·D-tap prototype has a ≈0.37·red transition band centred on red/2, so it is flat only to ≈0.31·red; the existing 0.45 guardFrac rule is NOT a passband guarantee for the shared stage, which is why the span rule above the floor was left untouched rather than "fixed") → 3 MS/s + 2 TETRA cells → red 375 kHz, D=8, per-tap 32 taps/output; the shared stage costs a flat 12 MACs per input sample whatever D is, so it now dominates the DDC and more taps are nearly free. (2) DDCBank.EnableWorkers (threshold 2 taps — a DDC tap resamples the whole reduced stream, unlike the channelizer's cheap fine-tune, so even two are worth splitting; per-tap mixBuf). (3) Resampler fast path: only the phases that emit are visited, mirrored 2N history (bit-identical, TestResamplerFastPathMatchesReference keeps the old walk as the reference). (4) mixInto in explicit float32 (NOT bit-identical — Go evaluates complex64 products in float64). (5) Airspy iqConverter: 12 symmetric-pair FIR over a mirrored window + unrolled 4-sample mix cycle, 110 → 213 MB/s (reference kept in iqconverter_ref_test.go, agreement 2e-6). Net: 0.39× → 0.18× wall, CPU 3.8 → 2.4 s per 10 s; remaining pump cost is the TETRA receiver's channel-filter FIR (~25 %) and the shared decimator. sdr.input_sample_rate (the systemwide pre-decimator) was missing from config.example.yaml (the 4 Sep rule) — added. Pi 4 confirmed (28 Oct): 2 cells 513 → 7 drops/10 min. The 4-cell residual (~9/min, cores mostly idle) was the depth-8 class AGAIN, in the Airspy driver: out held 8 URBs of 16 KiB = ~11 ms at 3 MS/s; both stream channels are now time-sized (400 ms, streamDepthFor, pinned by stream_depth_test.go). HackRF and AirspyHF still have a fixed 8-deep out AND a BLOCKING send in onPacket (the shape the Airspy reaper-freeze fix removed, airspy_overrun_test.go) — not changed, separate fix.
  • The NXDN "scrambler" (SACCH VCALL cipher type 1, 15-bit key) IS DECODED and capture-pinned (#1261, 26 Oct): the reporter's two known-key captures (Kenwood, keys 32767 / 12345 on 447.100 MHz NXDN96, nxdn/testdata/scrambler_key*_frames.json) descramble to speech (AMBE b0 pitch continuity 0.66 / 0.65 vs a 0.16 ciphertext floor), and a brute force over all 32768 keys ranks the programmed key FIRST on both. Three things the captures settled, each against GT's previous model: (1) the LFSR is DSD-FME's construction — out = state bit 0, feedback bit 0 ⊕ bit 1 shifted in at bit 14 — not the left-shift x^15+x^14+1 register scramble.go carried as an admitted working model; (2) the SCHEDULE is reseed-from-key at the SACCH superframe start (structure field 3), then CONTINUOUS 4×49 bits per LICH-valid air frame whether or not it carries voice (NXDN96 alternates voice frames with FACCH frames, which consume keystream too), slot v = bits [49v, 49v+49) — DSD's literal per-frame code order ranked the known key ~500th, the superframe-periodic reseed at the right phase ranked it 0th; (3) GT's SACCHCRC6 (poly 0x43, preset 0) was WRONG — it verified only all-zero blocks — the reference (MMDVMHost createCRC6 == DSD-FME crc6) is preset 0x3F / taps 0x27 and verifies 48/65 real-air blocks, which is what made the superframe (VCALL: src 8814, cipher 1, key id 3 / 1) and the reseed phase readable at all. Production: nxdn.VoiceCipherTracker (cipher.go) off TrafficChannel.OnFrame in the voice chain, holding voice until the first VCALL names the cipher (only when keys are configured), publishing call.encryption (protocol nxdn, alg id = cipher type); keys are algorithm: scrambler with a DECIMAL key, on a system or on a scan-list channel (ConvChannelConfig.EncryptionKeys → pseudo systems conventional:<hz> in the key resolver, since a scanner grant's System is "scanner"). Method lesson: dump the FEC-decoded frames once, then sweep hypotheses (register × schedule × phase) by pitch continuity + brute-force RANK of the known key — a 0.36 continuity "partial decrypt" is a wrong schedule, not a near miss. STILL ON-AIR-GATED (#764/#771): the reporter's live run with the key on the scan-list channel; harness TestNXDNScramblerReplay (GT_NXDN_SCR_IQ/_KEY/_BRUTE).
  • Two PCM rates reach the live paths, and nothing used to convert between them (#1242, 21 Oct). Decoded digital voice is ALWAYS 8 kHz (vocoder contract; the recorder already forces digital files to 8 kHz, #356), analog FM is at recordings.sample_rate. The web stream's WAV header reads the player rate (audio.sample_rate, else recordings), gRPC labelled a hard 8000, tone-out was built at recordings rate and fed the 8 kHz tap. cmd/gophertrunk/live_rate.go (liveRatesFor + wireLiveSinks + per-serial rateSink) converts each feed to its consumer's rate; default 8000 everywhere is pass-through (no resampler object). Old wiring at 48000: a 1 kHz digital tone came out at 5997 Hz on the stream (TestLiveSinksDeliverEachConsumerItsRate). The reporter's "recordings.sample_rate 8000 → 0 fixed the sped-up FLAC" CANNOT be the setting (digital FLAC is 8 kHz in STREAMINFO and every frame at any setting, TestRecorderForcesVocoderNativeRateFLAC); their own log put a host_drops=3873 overrun right before the bad audio — read IQ loss before believing a config "fix" (the narrowband_filter lesson again).
  • NXDN trunked voice IS ON-AIR VERIFIED (#1261, 21 Oct): lock, grant follow, Rdio upload with TG/RID, on the reporter's 10-channel 453 MHz system; NXDN48/96 conventional survives drift. Their next blocker was CONFIG, not DSP: the TUI panicked opening nxdn_band_plan's linear/table mode — configtui.resolve called FieldByName on the band-plan POINTER mid-path (cur() only dereffed the last step; DMR's band plan crashed too) — and the web builder had no editor because webschema_test.go allow-listed all four NXDN band-plan fields as "editable in the TUI". TestEveryLeafEditable classifies kinds but never DRIVES a drill path, so a TUI shape test needs keystrokes (bandplan_drill_test.go). The key was also missing from config.example.yaml. One call ended at the tracker's ±3 kHz bound after decoding cleanly (406 frames): an unmodulated carrier passes the variance gate and pulls it there (shown synthetically, unconfirmed on air); carrier_offset_at_voice_hz / iq_samples in the chain-ended line are the instrument.
  • The single-channel decoders (APRS/AIS/DSC/POCSAG/FLEX/M17/MDC1200/FleetSync) ran their FM discriminator on the SDR's FULL-RATE IQ with no channel filter (#1316) — every APRS frame on the reporter's 144.39 MHz dongle was garbage with fcs_ok=false. An FM discriminator's output SNR follows the carrier-to-noise over its whole input bandwidth, so 2.4 MHz of noise around a 12.5 kHz channel is ~23 dB of needless noise. All eight now run behind one shared narrowband.FrontEnd (internal/dsp/narrowband: LO offset mix → anti-aliased integer decimation to ~48 kHz → ±8 kHz Kaiser channel filter), the daemon tunes the dongle the conventional scanner's clip-safe LO offset BELOW the channel (518 kHz at 2.4 MS/s) so the RTL DC spur misses it, and every receiver is built for fe.OutRate(), not sdr.sample_rate (runSingleChannelDecoder now also retries tune/open/decode for ALL of them, ADS-B/LoRa included — they used to die on one failed SetCenterFreq). Failing-first: TestReceiverDecodesAtSDRRateThroughFrontEnd (2.4 MS/s packet, band-wide noise leaving the channel at +15 dB: raw receiver 0 packets, front end 1). Two fixture traps found on the way: (1) demod.FFSKModulator's default audio amplitude is 0.5 rad/SAMPLE, i.e. the FM deviation SCALES WITH THE SAMPLE RATE (±7.6 kHz at 96 kHz, ±191 kHz at 2.4 MS/s) — harmless for a receiver that demodulates the full stream, invisible to every self-consistent round trip, and nothing a channel filter can pass; SetDeviationHz / ModulateFFSKDeviation state the real deviation (siglab's MPT1327 fixture still uses the default at the SDR rate — check before putting a filter in front of it). (2) The skipped APRS synthetic test failed only because its closing flag ended on the very last sample and never cleared the resampler/timing-loop group delay — add trailing flags, as a real transmitter sends. Related in the same pass: a dongle a single-channel section pins by serial now opens as sdr.RoleDedicated (Hint.Dedicated, markDedicatedHints) so the trunking voice pool, the manual-tune/conv-scanner spare heuristic, hunt auto-selection and rigctld can never retune it — four auto sticks with APRS+ADS-B pinned used to make the ADS-B stick THE control SDR; and such a stick may run its own sample_rate. ADS-B: DF 0/4/5/16/20/21 were CRCValid=true BY CONSTRUCTION (ICAO = CRC XOR parity, no check at all), so an unfiltered dongle produced ~440 "aircraft"/s with unallocated ICAOs; ProcessFrame now accepts them only for an ICAO a CRC-checked DF 11/17/18 proved within 60 s (Tracker.KnownICAO, dump1090's rule), and DF 11 with a non-zero IID is accepted. A preamble NOISE-FLOOR test was measured and REJECTED: the shape test already selects local maxima, so a 6 dB margin removed <1 % of the ~2 600 noise preambles/s while costing weak real frames. RR import mapped "Phase II" to p25-phase2 (the TDMA CONTROL-channel pipeline) — a Phase II system's CC is Phase 1 FDMA; it is p25 now. All on-air-gated (#764/#771).
  • wM-Bus (#1256): the field's CRC failures were a TUNER OFFSET + a too-wide filter, after the clock-slip fix. The reporter's 156 -save-frames snippets (S5 + RTL-SDR, -gain 0, Qundis HCAs + Diehl water meters) are the yield check: GT_WMBUS_SNIPPETS=<dir> go test ./internal/radio/wmbus -run RealAirSnippets -v ("live" = one receiver over all snippets in time order, as the run heard them). 74 CRC-valid on v1.3.1 → 97 with the mid-chip clock (f5448d9, missed v1.3.1) → 128 with carrier tracking. Every meter sat ~−50 kHz (58 ppm, the dongle's error), so the 170 kHz filter that keeps the far tone admitted noise from +170 kHz; the receiver now learns the offset from CRC-valid frames (the frozen slicing threshold IS the preamble's carrier) and narrows to 100 kHz around it. The per-frame residual must be measured against the correction the BLOCK WAS MIXED WITH (appliedHz) — a retune mid-chunk otherwise doubles the next frame's estimate (TestReceiverReportsCarrierOffset). Don't trust a spectral centroid for an FSK carrier (read −62 kHz here; the decode optimum is flat at −50..−60). Their gain0 capture uses ~3 bits of the u8 ADC; the gain20 one clips (0.1 % at the rails).
  • **wM-Bus walk "GopherTrunk stopped recording" (#1256, 1 Nov) was two things, neither the decoder: the RTL-SDR dropped off the phone's USB bus (usb: device disconnected, 2 of 4 runs ended; wmbus now reopens it, -reconnect), and stretches where the decoder got ~12–14 % of real time. Check it with arithmetic: drops × 8192 samples (5.12 ms at 1.6 MS/s) equalled the run's wall time minus its stream time, so USB delivered everything and the process was starved (screen off / Termux backgrounded; the banner showed cpus=2 on a 4-core S5). Progress lines now carry the time of day and the drop WARN states the real-time share. Replay check: the 319 snippet WAVs give exactly the live log's 126 CRC-valid on v1.3.6, 142 on main (#1333). Also: a diag.Reporter prompt must never read a stdin the q stop watcher owns (PromptAllowed).
  • SoapyRemote RPC opcodes are an upstream enum, and a fake server cannot check them. callSetAntenna was 600, which is HAS_DC_OFFSET_MODE; SET_ANTENNA is 501 (pothosware/SoapyRemote common/SoapyRemoteDefs.hpp). The wire carries no schema, so the wrong id silently invoked a different handler whose first two arguments happened to match: antennas: [RX1, RX2] did nothing, every radio kept its driver default (which is why an X310 and a B210 behaved differently on one config), the leftover string produced the operator-visible ~SoapyRPCUnpacker: Unconsumed payload bytes 9 once per channel, and the bool reply carried no exception so rpcVoid logged "rx antenna set" for a call that never happened. The unit tests could not catch this: driver_test.go's fake server switched on the same constant, so both sides moved together — the #764/#771 self-consistent-synthetic trap in a new dress. Two nets now, and they catch different things: the fake asserts every request body is FULLY CONSUMED (mirroring the real ~SoapyRPCUnpacker, wired into newFakeSoapyServer so every test gets it) which catches ARGUMENT-SHAPE drift and found two more calls the fake had not been parsing; and TestOpenSetAntennaUsesUpstreamOpcode pins the numeric opcodes against upstream LITERALS, which is the only thing that can catch OPCODE drift. applyAntennas now validates against LIST_ANTENNAS (500) and reads back with GET_ANTENNA (502) — port names are device-specific (B210: TX/RX, RX2; TwinRX: RX1, RX2) so a config moved between rigs must fail loudly.
  • SoapyRemote CS16 is NOT always 16-bit full scale (#1329). GET_NATIVE_STREAM_FORMAT replies with the format AND a double full scale, and the reference client divides by it whenever the wire format is the native one (client/Streaming.cpp setupStream, ClientStreamData.cpp). A PlutoSDR / AD936x board (SoapyPlutoSDR) streams 12-bit LSB-aligned CS16, full scale 2048; GT divided by 32768 and read every Pluto signal 24 dB low (dBFS, squelch_dbfs, overload checks). sampleFormat.withNativeFormat now ports the rule; UHD (32768) and non-CS16-native devices are unchanged. The fake server used to reply fullScale 1.0, which nothing real sends; it now reports and streams at a configurable nativeFullScale. On-air-gated: no Pluto run yet.
  • Never gate a DSP decision on absolute dBFS — gate on coherence. MRC calibration was gated on the reference branch clearing −40 dBFS, so an operator raised front-end gain from 65.0 → 82.0 dB purely to push a number past a software constant (they landed at −39.8 dBFS, clearing it by 0.2 dB). Any absolute-power gate is a gain-staging trap that re-fires on the next front end. The scale-invariant question is the normalised cross-correlation |rho| = |Σ x1·conj(x0)| / sqrt(Σ|x0|²·Σ|x1|²) (diversity.CrossStats), which is also what actually decides whether a gain estimate is trustworthy. |rho| = γ/(1+γ) for equal per-branch SNR γ makes thresholds interpretable (0.50 ≈ 0 dB, 0.35 ≈ −2.7 dB) against a noise-only floor near sqrt(π/4N). Absolute power survives only as a digitally-dead-branch reject at −100 dBFS. DC removal in the correlator is load-bearing, not hygiene: both receivers of a front end share LO leakage, and an UNCENTRED correlator on two branches of independent noise plus a common DC reports |rho| → 1 and freezes h = dc1/dc0 — confidently calibrating on nothing, in exactly the weak-signal regime the gate protects. Pinned by TestCrossStatsRejectsCommonDCOffset.
  • Tracking MRC is safe ahead of a differential decoder; CMA was not — and the difference is structural, not a tuning choice. diversity.TrackingCalibrator re-estimates the branch gain per 2 ms window and smooths one-pole (τ ≈ 200 ms), because a frozen constant is only right for a shared-LO single-chip front end. The reporter's rx_subdev_spec=B:0 A:0 puts the branches on separate TwinRX daughterboards with independent PLLs — frequency-locked to a common reference, but random relative phase per lock and walking after it — so a constant decays. The SnapshotCMA lesson does not transfer: CMA's cost is rotation-invariant so nothing constrains its absolute output phase, whereas here h_0 is pinned to exactly 1+0j, so the output phase is ANCHORED to the reference branch's own and only the estimate ERROR can move it (arg(y/x0) ≈ −ε·|h|²/(1+|h|²), zero at convergence). That claim is measured, not asserted: TestTrackingCalibratorIsDifferentialSafe bounds the per-window output phase step against the 45° π/4-DQPSK decision spacing. A rejected window holds the previous gains and never drops back to passthrough — falling back mid-stream is itself a large phase step, the exact failure class being avoided. mrc-static (α = 0) is the one-shot escape hatch; the first accepted window is snapped, not smoothed, so it is bit-identical to a one-shot LS calibration and both modes start the same way. Also fixed: the reference branch was re-chosen by argmax on EVERY datagram while uncalibrated, so an ordinary ~1 dB crossover between two healthy receivers swapped the phase anchor mid-stream.
  • MRC combines the WIDEBAND stream, so one complex scalar optimises whole-band SNR, not the target channel's. That is exact only if the branches differ by a frequency-flat constant. Two antennas metres apart give each carrier its own phase difference set by geometry and direction of arrival; a scalar aligns whichever is loudest and partially cancels the rest. The signature is a wideband coherence stuck around 0.3–0.5 that no amount of tracking improves, and the fix for that regime is combining AFTER the per-channel DDC (one gain per narrowband channel) — a much larger change, not built. The coherence figure in the health line is what makes this visible instead of silent, which is arguably a bigger contribution than the gain-independence.
  • Diversity captures must be PRE-combine, or they answer nothing. The combiner lives in the driver, so baseband.auto_record, the iqtap brokers and the scope taps are all downstream of it — a capture from any of them has one combiner already baked in and cannot be replayed through another. sdr.soapy_remote[].diversity_capture taps the branches straight after de-interleave, writing one headerless cs16 file per branch (each independently playable via replay -format cs16) plus a sidecar. Alignment is the invariant: a datagram that did not carry every branch is dropped from BOTH files and counted, never written short, because one short write silently desynchronises them and every later conclusion is wrong with nothing to show for it. A/B with GT_DIVERSITY_CAPTURE=<prefix>.diversity.json go test ./cmd/gophertrunk -run TestDiversityCombinerReplay -v: it prints a windowed coherence/gain/phase trace (flat phase ⇒ the frozen constant was fine on that hardware; walking phase ⇒ tracking is doing real work, in °/s) and decodes four arms — each branch alone, static, tracking — scored by CRC-clean BSCH. Yield is the verdict, never EVM. Tracking-as-default is NOT yet verified on air; that A/B on the operator's own capture is the gate.
  • The A/B ran on the operator's first pre-combine capture (17 Aug, X310 rx_subdev_spec=B:0 A:0, internal clock, TETRA CC 467.9125 MHz, 250 kS/s, 30 s, 0 dropped datagrams) — the X310 dual-daughterboard stream over SoapyRemote IS coherent, no external oscillator sync needed. Wideband coherence median 0.658, narrowband (post-DDC 144 k) 0.713; branch phase ~145° walking only −0.22°/s (frozen constant decays over minutes, so tracking is right, but static is fine over any one calibration). Decode arms: branch0 885 / branch1 896 / wb-static 898 / wb-tracking 898 / nb arms 896 CRC-clean BSCH — MRC ≥ best branch (no harm) but this capture sits at its ~96% BSCH ceiling, so it CANNOT demonstrate a real gain; a weak-signal capture (BSCH well below ceiling on each branch alone) is what closes the tracking-as-default gate. Two calibrated readings of the same capture disagree by design, don't chase them: the live health line logged coherence 0.28–0.55 / branch_gain −11..−17 dB where the offline harness measures 0.66 / −6.4 dB power ratio — the driver's per-window LS estimate is biased low by noise (the logged gain IS the MRC weight, ∝ ρ·√(P₀/P₁), not a power ratio). Related bug found and fixed in the same pass: mrcTrackAlpha derived the loop coefficient from the NOMINAL 2 ms window while mrcCalWindowSamples clamps to ≥4096 samples, so at 250 kHz (16.4 ms windows) the tracking 1/e time was silently ~1.6 s instead of the documented 200 ms — alpha now follows the actual window duration (TestMRCTrackAlphaHonorsClampedWindow). Operator-visible symptom ruled OUT as a regression: the "bad" web constellation at 250 kHz is the honest π/4-DQPSK symbol plot (the symbol_proto fix stopped TETRA rigs drawing the flattering raw-IQ ring of a wrong P25 receiver) of a marginal signal plus ~400 Hz carrier offset (≈8° differential rotation); the same run decoded ~96% BSCH and recorded voice.
  • MMSE-IRC cannot work blind, and that is a property of the estimator, not of one formula. The reporter's #1062 RFC proposes IRC to null a directional interferer that MRC amplifies. Two separate problems, and only the first is fixable inside the algorithm. (1) The RFC's residual e_k = x_k − h_k·x_0 is degenerate: h is estimated by least squares against x_0, so h_0 = 1 by construction and e_0 ≡ 0, R_nn's reference row and column are zero, the diagonal-loading constant alone sets the reference weight and no null can form. Taking the residual against the MRC output instead fixes that. (2) The channel estimate itself is contaminated: with a co-channel interferer cov(x_k,x_0) = h_wanted·P_signal + h_interf·P_interf, so LS returns a POWER-WEIGHTED BLEND — measured, a true 0.95∠40° reads back as 0.32∠1°, and the null is steered at a mixture. Blind IRC measures 0.0 dB over MRC on the synthetic co-channel scene; the same code given a training sequence measures +23.6 dB (internal/dsp/diversity/irc_test.go). A training sequence exists only AFTER the per-channel DDC — which is also where the per-channel combining the wideband limitation calls for has to go, so both roads lead past the DDC. IRCCalibrator is therefore an offline harness arm, not a driver mode.
  • The replay harness now measures narrowband coherence too, and that is the number that decides the architecture. TestDiversityCombinerReplay traces coherence on the wideband stream AND after each branch's own DDC, then scores eight arms (add wb-irc-blind, nb-static, nb-tracking, nb-branch0-only). High narrowband against low wideband is direct proof that the single wideband gain is the limitation on that hardware. A calibration that locks once and then holds every window afterwards now WARNs after three health intervals — the operator's 17 Aug X310 log had updates frozen at 6682 for eleven minutes while holds climbed ~6000 per interval, and the line said INFO the whole time because calibrated was still true.
  • branch_phase_deg in the MRC log line is the no-capture field instrument. Constant ⇒ shared-LO front end; walking ⇒ independent PLLs. It tells an operator which hardware class they have, and therefore whether mrc or mrc-static is right, before anyone records anything.
  • A fixed coherence threshold was a BANDWIDTH-staging trap — the MRC gates now bound the ESTIMATE'S phase error, not |rho|. Wideband |rho| is diluted by every hertz of noise-only bandwidth around the coherent carrier (rho_wb ≈ rho_ch·sqrt(f0·f1) for in-channel power fractions f_k), so the fixed 0.50 lock constant made calibration depend on the configured capture bandwidth and each branch's own noise floor. Proven by the operator's 18 Aug X310 A/B (three 30 s pre-combine captures): at 200/250 kS/s + 70 dB gain the CC decoded 1425 CRC-clean BSCH off branch 1 while wideband |rho| sat at ~0.16 (phase measurable to ~4° per 4096-sample window, walking only −7°/s) and MRC NEVER calibrated — updates=0, WARN "check antennas/clock, raising RF gain will NOT help" — until +5 dB of gain pushed the number past the constant (their branch 0 is ~9 dB down with a gain-independent floor, so +5 dB lifted its in-channel fraction 0.14→0.37 and the diluted rho with it: the WARN's claim was exactly wrong in that regime, and the text now points at per-branch gain staging instead). The gates (diversity.TrackingOptions.LockPhaseSigmaRad/TrackPhaseSigmaRad, defaults 0.10/0.16 rad) accept a window when the LS estimate's projected phase error sqrt((1−ρ²)/(2Nρ²)) is bounded, i.e. ρ ≥ 1/sqrt(1+2Nσ²) — falls as 1/√N yet stays ~8×/~5× above the noise-only floor sqrt(π/4N) (false accept ~e^−50 / 3e−9 per window; noise still can NEVER lock, pinned by TestTrackingCalibratorNeverLocksOnIndependentNoise). The health/WARN line logs the effective lock_gate for the stream's window. Pinned failing-first by TestTrackingCalibratorLocksOnBandwidthDilutedCoherence + TestMRCCombinerCalibratesOnBandwidthDilutedCoherence (fixture ρ≈0.2 locks, recovers the true phase to <5°; under a 0.5-equivalent bound it never locks). Post-fix replay of all three captures: every arm calibrates on every run and wb-tracking matches the best branch within 1 BSCH (run1 1424 vs 1425, run2 401 vs 402, run3 1591 vs 1591 — no harm, and no gain is expected while branch 0 is floor-limited), and the harness arms now anchor on the LOUDER branch like the driver — they used to anchor on file-order branch 0, so on these captures (weak br0) every combined arm degenerated to the weak branch's passthrough and measured nothing. Two things the gate change does NOT alter: a genuinely dominant-carrier-misaligned rig (antennas metres apart) is still the wideband-scalar KNOWN LIMITATION — per-bin cross-phase on these captures shows distinct per-carrier phases (−157°/−87°/+20°) but the camped CC carries 71–94% of the cross-power so the scalar aligns it — and MRC on a 9 dB-down floor-limited branch is ~no-gain either way (the honest fix for run1/run2 is raising branch 0's gain, which run3 proved).
  • 19 Aug X310 field log (10.5 h) — three verified failure modes, all fixed; and the real MRC bottleneck on this rig turned out to be an INTER-BRANCH TIMING SKEW, now aligned in the driver. The operator swapped the antennas between RX1/RX2 mid-run: the ~5–10 dB branch deficit FOLLOWED the swap ⇒ antenna/feedline, not the TwinRX. Findings, each with a failing-first regression:
    • TETRA CC "locked but deaf" ~37 min total (12 min in one stretch, log ends in it): the AFC's EMA can latch the wrong ±f_sym/4 alias bucket. A decode-drought resync re-primes omegaEMA from ONE weak block; omegaAliasReject (π/4 = 2250 Hz) then discards every CORRECT estimate forever. BSCH survives the constant ~17°/sym residual (4-rotation search + heavy FEC) while all SCH fails ⇒ bsch_ok ~100%, sch_pdus=0 — and every BSCH stamps the ONE activity heartbeat both the 1.5 s resync and 5 s CheckStale feed on, so no recovery could fire (the #815 WARN is advisory). Fixes: (1) carrierAFC.track re-primes after 3 consecutive rejects that agree with each other (afc_reprime_test.go); (2) a second payload heartbeat (LastPayloadNano, stamped on CRC-clean SCH/F, SCH/HD, or the SB's SCH/HD-coded BNCH) drives checkPayloadResync: 12 s of signal with a live lock and no payload forces a resync, 3 fruitless resyncs escalate to MarkLost → re-hunt (mirrored in widebandt2/tetra.go). Also: a single mis-corrected BSCH could rewrite the locked MCC/MNC (4 bogus "tetra cc locked" flaps, mcc=996 etc.) — identity CHANGES now need 2 consecutive agreeing decodes, mirroring colourConfirmThreshold.
    • "AFC spikes to −5 kHz" on a locked, centred TETRA CC = the 19 Aug re-prime escape hatch FIRING FALSELY on an alias-class transient — fixed with a confidence gate, and the #815 wrong-site WARN now requires persistence. A reporter filmed the mixer panel's carrier readout jittering ±400 Hz then spiking to ~−5 kHz; the log's #815 WARN said offset_hz=5004 on a carrier really ~500 Hz off. 5004 = 504 + 4500 and f_sym/4 = 4500 Hz: the spike is EXACTLY one AFC alias bucket, not a carrier move. Mechanism: the per-block raw estimate is coarse (spectral centroid) + fine (4×Δφ, unambiguous only within ±f_sym/8 = ±2250 Hz), so ANY transient coarse bias > 2250 Hz (a neighbour's skirts through the channel-filter edge while the wanted carrier fades) lands the raw estimate exactly ±4500 Hz off — and three such consecutive blocks (~170 ms) tripped the 19 Aug clustered-reject re-prime, slamming a long-corroborated EMA into the wrong bucket (SCH fails ~100% there; BSCH survives via per-burst SB correction, so the lock looks healthy). The two failure modes are the SAME signature at different ages: a wrong one-block seed (19 Aug) needs a FAST escape, a transient on an established track needs a HOLD. Fix (afc.go): track counts accepted blocks since the last prime (accSincePrime); an alias-class reject cluster (cluster mean ≡ EMA mod π/2, aliasClassOffset) on an established track (≥16 accepted blocks) needs omegaReprimeStreakEstablished=18 blocks (~1 s) to re-prime, while fresh tracks and non-alias clusters keep the fast 3-block escape; a wrong slow re-prime self-heals fast (it leaves a fresh track) and checkPayloadResync bounds the worst case. Pinned failing-first by afc_alias_transient_test.go (old code jumps to 4950 Hz after exactly 3 alias blocks). Companion fix (ccdecoder/decoder.go): the #815 WARN diagnosed a persistent condition from ONE per-chunk instantaneous sample, so every sub-second estimator blip produced a scary wrong-site WARN — the "fake triggering of freq offset" report; it now requires the total offset to sit over threshold continuously for carrierOffsetWarnPersist (10 s, test-overridable field), with the excursion clock reset on dips and on pipeline teardown. Pinned by TestDecoderCarrierOffsetWarnRequiresPersistence.
  • The MRC anchor flipped mid-stream on the 08:58 cc-hunt retune (rearm set refIdx=-1 → bare argmax re-pick), and the applied gain random-walked to −34 dB with calibrated=true (divergence bounds gated only the proposal). Fixes: rearm/setSampleRate/short-payloads KEEP the anchor (dead-incumbent escape still works), every anchor change is logged, SetCenterFreq short-circuits same-frequency retunes, and clampMagnitude floors the APPLIED gain (smoothed branch only — the snap/mrc-static equivalence pin is untouched). Differential safety is now also pinned at the driver's real α=0.1024 (200 kHz clamped window; the τ comment claimed windows ≪ a burst — false at low rates, corrected).
    • The replay harness's "tracking" arms were silently STATIC (TrackingOptions{Alpha: 0} = one-shot), so the 17/18 Aug "tracking matches best branch" verdicts compared static to static. Arms now derive α like mrcTrackAlpha and fatal on α=0.
    • The big one: branch 0 lagged branch 1 by a CONSTANT 2.60 samples (13 µs at 200 kS/s). Per-frequency coherence 0.99 with broadband ρ diluted to 0.78 is the signature of a pure delay — it is why this rig's wideband coherence never exceeded ~0.8 in ANY session. A scalar gain cannot represent a delay, so combining skewed branches decoded 22% FEWER BSCH than the best branch alone (886 vs 1142 on the 19 Aug capture) — MRC was HURTING; the identical combine after alignment scored exactly 1142/0. soapyremote.branchAligner now measures the skew per stream/retune (0.65 s buffer, ±16-lag scan + parabolic fraction, DC-removed, ρ≥0.1 latch gate) and delays the EARLY branch through an interpolating delay line, resetting the calibrator on latch so it locks on the aligned stream. The harness measures/reports the skew and carries a wb-aligned-static arm. Diagnose with TestDiversityCombinerReplay: "inter-branch delay: other lags ref by N±f samples". Open question for the next capture: whether the skew is per-stream (start skew) or fixed (DDC group delay) — the aligner re-measures per stream either way. A post-fix long run from the operator is still the on-air gate (#764/#771 discipline). The symbol/spectrum/diag/mixer handlers resolved the SDR serial AFTER upgrader.Upgrade, so an unknown device produced a successful 101 followed by a 1011 close. Browser clients reset their reconnect backoff in onopen, so a handshake that always succeeds makes MAX_BACKOFF dead code: one stale tab across a daemon restart with different hardware reconnected 2–4×/s forever, across six mount sites. Fixes, all three needed: resolve the device BEFORE the upgrade and return 404 (api.ErrUnknownDevice, rejectUnknownDevice); reset the client backoff only once a frame actually ARRIVES (or the socket held open past a grace period), not on onopen; and reconcile the selected serial against a refreshed device list instead of setting it once and never revisiting. web/src/api/reconnectingSocket.ts now owns the logic the four clients had each copied — including the onerror+onclose double-bind that scheduled two timers per failure while remembering only one handle.
  • 29 Aug X310 field material (18.5 min debug.log + 60 s pre-combine capture at 200 kS/s) — the 19 Aug fixes HOLD on air, and the skew question is answered: PER-STREAM. After the operator fixed the weak antenna/feedline (the 19 Aug deficit followed the swap), the branches sit balanced (~−51 dBFS each, branch_gain within ±1.4 dB) and the whole session is clean: wideband coherence 0.95–0.96 every health interval (vs ≤0.8 in every pre-aligner session), updates climbing continuously with holds=0, no anchor flips, ONE benign WARN in the whole log, CC locked in 0.3 s and never lost (bsch_fail=0 in every 5 s status line), 0 overruns, 3 drought resyncs in 18.5 min, voice flowing (demux total tch_frames=7052, vocoder_drops=0 per call). The inter-branch skew measured 0.41 samples on this stream where 19 Aug measured 2.60 on the same rig — so the skew is a per-stream START skew, not a fixed DDC group delay, and the aligner's re-measure-per-stream/retune design is the right one (latched at peak |rho|=0.94). Offline A/B on the capture (TestDiversityCombinerReplay): branch0 2589 / branch1 2626 / wb-static 2625 / wb-tracking 2626 / wb-irc-blind 2626 / wb-aligned-static 2625 / nb-static 2625 / nb-tracking 2626 — every combined arm matches the best branch (NO harm, the pre-aligner 22%-loss regime is gone), narrowband coherence (0.958) ≈ wideband (0.945) so the wideband scalar is NOT the bottleneck on this rig, and phase walks −0.11°/s (a frozen constant decays over minutes ⇒ tracking stays the right TwinRX default). What this capture CANNOT close: it decodes at its ~100% BSCH ceiling, so a real MRC gain over the best branch is still undemonstrated — that gate needs a WEAK-signal capture (per-branch BSCH well below ceiling), not a longer one. diversity_capture_seconds cap raised 60→120 on the operator's request (at 200 kS/s two CS16 branches are ~1.6 MB/s total; the 1 GiB/branch recorder cap still bounds high rates). The demux teardown counters that look alarming in this log (undecoded_drops=24784, concurrency_suppressed=33125) are by-design cross-slot-leak protection on a busy multi-slot carrier, not defects.
  • A green ci.yml does not mean the web console builds. npm test (vitest) transpiles with esbuild and never typechecks, so the SPA jobs in ci.yml pass on code that npm run build (tsc --noEmit && vite build, i.e. make web-build/make dist) rejects. Three TS6133 unused-parameter errors in web/src/api/reconnectingSocket.test.ts landed on main that way and broke the build for an operator. The only job that caught them was "Windows installer (PR)" — which is not a required check, so the PR merged red and it read as "CI passed". Two things follow: when a web change is in the diff, run cd web && npm run typecheck (or make web-build) before pushing — npm test alone is not sufficient evidence; and check a PR's non-required jobs, not just the required ones. ci.yml's web-typecheck job now runs tsc --noEmit across all five SPAs (web, configbuilder, siglab, rfscope, cryptolab — the last two are built by no other workflow). Note tsconfig.json sets noUnusedLocals/noUnusedParameters, so an unused parameter needs a leading underscore (_fn), and test files are inside include: ["src"] and are typechecked like production code.
  • A graceful-shutdown window is a lie unless something tells the handlers to leave. Ctrl-C took a fixed 30 s. Daemon.Close stops the HTTP server FIRST, and http.Server.Shutdown waits for active non-hijacked requests without cancelling their r.Context() — while the bus.Close() that would end handleSSE runs at the very END of Close, ~90 lines later. So any attached SSE / live-audio / siglab subscriber pinned the whole window, then Shutdown returned context.DeadlineExceeded, which daemon.spawn counts as a clean exit. Completely silent. The window had already been widened 5 s → 30 s "to cut user-visible connection drops", which made the pause 6× longer without making anything drain — the reason this kept coming back. api.Server now closes a stopStreams channel at the top of shutdown() and the long-lived handlers select on it; the 30 s is a CAP for a handler that misses the signal, and reaching it WARNs. Two more teardown stalls hid behind it: GRPCServer.Stop was an unbounded GracefulStop (an open StreamAudio waits forever), and the soapyremote/rtl_tcp read loops checked ctx.Done() only BETWEEN reads and then parked in io.ReadFull behind a 30 s deadline — a quiet server is the normal state at shutdown, so both added their own 30 s tail. Watch for this shape anywhere: a bounded wait whose exit condition is satisfied later in the same teardown.
  • cmd/gophertrunk/integration_test.go used to give Run 3 s and then move on silently, which is why a 30 s shutdown stall never failed CI. A cleanup that swallows a timeout is not a test. It asserts now.
  • Every SDR source list has to be named in the pool-construction gate at daemon.go. The gate read SDR.Devices || Baseband.Replay || SDR.RTLTCP and the network drivers are registered INSIDE that block — so a config with only sdr.soapy_remote (or only ka9q_radio) registered no driver at all and the daemon started quietly with no radio. Adding a new source type means adding it to that condition; TestRemoteOnlySDRConfigsRegisterTheirDriver pins it, asserting on REGISTRATION rather than on d.pool because the pool is set to nil whenever it fails to open.
  • The SPA client's response-envelope keys are unchecked and fail silently. The call-history client read r.rows where the daemon sends {"calls":[…]}, with a ?? [] fallback — so the History panel rendered "No calls in the daemon's call log for this filter" for every query since the file was written, and it read as "search by talkgroup is broken" while the handler, the filter and the SQL were all correct. ?? [] on a mistyped key is indistinguishable from an empty result. Panels without a test are where this hides; History had none.
  • Operator-applied names live in a labels table, layered over the alias files at startup. PATCH /api/v1/rids/{id} used to 404 for a radio absent from rid_alias_file — i.e. exactly the radios worth naming, since one showing up live is by definition not in a file yet — and PATCH /api/v1/talkgroups/{id} had no name field at all. Both now create the catalogue entry, using the CSV loaders' own defaults so a synthesised row behaves like a loaded one. A synthesised talkgroup must NOT be tagged Discovered: DeleteDiscovered would sweep away the operator's name behind their back. The label wins over the file (it is the operator's most recent explicit act) and logs a WARN when they disagree. Keyed (kind, system, target_id) because talkgroup_file / rid_alias_file are per-system config keys and the CSV export has to be able to emit one file per system. Only the naming fields persist; priority / lockout / watch / scan stay in memory as before.
  • Two systems on one role: control/auto tuner are TIME-multiplexed, never concurrent (10 Sep "can't monitor two TETRA systems at once"). cchunt.Supervisor hunts systems round-robin and parkUntilUnlocked on the first that locks — on a healthy site the second is never hunted, with no log line saying so (only systems=2 at startup and the web UI's system list). channels:/voice_taps/tuner_strategy on a non-wideband device are dead config (gated on Role == "wideband" in daemon.go and config_validate.go). The daemon now WARNs + addWarnings both conditions at startup (TestTwoSystemsOnOneControlSDRWarns). The concurrent path is role: wideband with one channels: entry per system; TETRA there used to have NO same-carrier voice source (a TETRA grant on a wideband-hosted CC could only bind a 48 kHz wbvoice.VirtualTuner and run the SOLO chain, never the 4-slot per-carrier demux), so widebandt2.ChannelVoiceSource (channeliq.go) now fans each wideband channel's own tap IQ out (engineChannel.fan, mirroring ccdecoder.voiceFanout) and the daemon registers four of them per wideband TETRA channel (buildWidebandSameCarrierVoiceTaps, d.wbVoiceSources, serial wb:<serial>:same-carrier:<hz>:<n>, CarrierKey keyed by engine AND frequency). Ordering matters: they are listed BEFORE the VirtualTuners in collectVoiceDevices because a VirtualTuner's CanTune accepts the CC carrier too and would otherwise take the grant into its solo chain; DMR Tier II on wideband deliberately keeps the VirtualTuner path (its own DDC from raw IQ, on-air-verified; the channelizer's bin-edge tap is the weaker one). NOT on-air-verified yet (#764/#771): the operator must run the wideband two-TETRA config and confirm both CCs lock AND voice records for both.
  • DMR Tier II IPSC "calls completely missed while the radios decode" (10 Sep) = a re-key within hangtime deduped against a call the control path still tracked. The operator's own log: the composer's voice-path TerminatorDetector released each call at PTT release, while the tier2 slicer's copy of the same terminator decoded 0.8–6.5 s LATER (its polyphase bin-edge tap is weaker; terminator slot type without a BPTC-valid payload ignored), so a reply's Voice LC Header for the same (tg, radio) hit Same call's repeated header — dedupe and was never granted. Fix (conventional.go): a header for a tracked call more than headerRekeyDibits (0.25 s) past that call's anchorDibit (its own last header / the LC that late-entered it) is a NEW transmission → releaseCall + re-grant (Rekeys counter, rekeys in the widebandt2 activity line); a fresh embedded LC after superframeRekeyGapDibits (2 s from the last LC's superframe start) re-grants by late entry when the header was lost too. Gotcha that cost a round: Process assembles a chunk's superframes BEFORE slicing its data bursts, so the new over's first superframe refreshed the call ahead of its own header — measure against the header/late-entry anchor, never the last superframe (convCall.touch only moves forward). Real-air A/B in TestDMRIPSCReplay (now reads wav/flac containers directly): GT_DMR_DROP_TERMINATORS=1 on dmr_ipsc_beacon_voice.flac — old 1 grant / 5 transmissions, new 5 (rekeys=4); unscrubbed 5 grants (one on-air re-key had no terminator at all), GT_DMR_DROP_HEADERS=1 unchanged at 4 late entries. The harness also prints a grant/release/voice-terminator timeline (stream time, 4800 dibits/s). Mid-hangtime the repeater repeats the LAST terminator of the OTHER slot too (tg 25862 on these captures) — harmless, dest ≠ call.
  • The IPSC "camp / idle beacon" is the ETSI DMR Idle burst (slot type 9), not a CSBK, and beacons=0 was because IngestBurst dropped Idle on the floor. On the operator's 10 Sep beacon-only capture a keyed-but-idle Motorola/Hytera repeater fills BOTH slots with BS-Data-synced Idle bursts (cc 12, ~33/s in ~10 s trains) whose BPTC-decoded block is always ff83df1732094ed1e7cd8a91 (2402/2416 bursts) — and BPTC-decoding MMDVMHost's DMR_IDLE_DATA template (DMRDefines.h; its slot type is a placeholder the host stamps) yields the same 12 bytes with zero corrections (TestIdleInfoPatternMatchesMMDVMHostConstant, the independent-reference pin). handleIdle counts a BPTC-clean Idle carrying exactly IdleInfoPattern as a beacon, declares the lock (site camped while idle) and logs site alive (idle beacon) rate-limited; a BPTC-clean Idle with any other block is logged parked with info_hex (vendor variant instrument), never counted. Side effect: the widebandt2 "strong in-channel signal but no sync" hint no longer fires on beacon trains (activityClass counts beacons as decode). The 9 Sep cc=7 CSBK-CRC-fail train did NOT appear on these captures (only a few Golay-forged slot types on voice bursts), so it is still unpinned.
  • Vocoder "sounds awful" is now MEASURED against dsd-neo on the same frames (10 Sep pairs) and is two calibratable things, neither in the vocoder core. Per-harmonic DFT at l·ω₀ of both decodes: f₀ identical; VOICED IMBE harmonics match within ±1 dB above 1.5 kHz; below that GT has a smooth LF excess (+4 dB @500 Hz … +16 dB @125 Hz = a first-order HPF shape — dsd-neo runs use_hpf_d=1 by default, a handset tilts the same way) → new recordings.enhance.tilt_hz first-order high-pass (radioTilt, bilinear pre-warped; the naive RC recurrence is 1.2 dB off at 8 kHz), default 450 Hz = the LSD-optimal corner once the unvoiced level is also fixed (750 fits the voiced harmonics alone). UNVOICED bands were 6 dB (IMBE) to 13–15 dB (AMBE 2450) low: mbelib's unvoiced harmonic is 3 random-phase cosines of ≈1.353·Ml (≈2.75·Ml², 5.5× a voiced harmonic), while GT's §6.4 FFT-noise path scaled bins by Ml alone (≈(2·bins/N)·Ml², pitch-dependent) → ShapeUnvoicedSpectrumGain normalises per band (recordings.unvoiced_gain, default mbe.DefaultUnvoicedGain=5.49, 1 = equal power, <0 = legacy; raw decoders stay legacy for goldens, the recorder and gophertrunk decode set it, decode -legacy-synthesis opts out). Long-term LSD to dsd-neo 300–3400 Hz: P25 male 8.3→1.5 dB, DMR male 4.9→2.1 dB. STILL OPEN: AMBE 2450 male VOICED harmonics −3..−6 dB above 1 kHz (IMBE doesn't show it; the DMR female goes the other way) — that one IS in unpackParams2450, and needs the mbelib-neo 2450 frame diff. Measure with band fractions / centroid / LSD, never by ear alone; the "gt-shipped" WAVs had enhance+normalize+warm on top and hid all of this.
  • 10 Sep user-reported batch — five web/recording reports, one root cause each. (1) "History updates too slow / stops at 00:15 while activity shows 03:15" was the UTC-render bug, not staleness: History/Events/Dashboard/Active/Tones/Scanner sliced the daemon's RFC3339 string (.replace("T"," ")), so a UTC+3 operator saw every table 3 h behind the (locally formatted) activity feed. lib/formatTime.ts (formatLocalDateTime/formatClock) is now the ONLY way to render a timestamp — grep for replace("T", " ") / toISOString().slice before shipping a panel. History also refetches after each call.end (+30 s visible-tab poll). (2) "duration 30 s, recording 4 s" = per-transmission segment rolls: the recorder publishes one CallComplete PER OVER stamped with the over's own StartedAt, and the call log keyed on it — only the first over matched the row, later files were orphaned. CallComplete.CallStartedAt/Segment + the call_recordings table fix the keying; /calls/{id}/audio concatenates the segments (concatRecordingSegments, inter-over gap restored, capped 1.5 s). Pinned by TestCallLogAttachesEverySegmentRecording, TestCallAudioEndpointPlaysEverySegment, TestRecorderSegmentCompleteCarriesCallStart. The residual "1.7 s audio of a 5.6 s over, 58 frames, audio_pct=31" in the same log is TETRA TCH/S decode yield on a −53 dBFS same-carrier signal (the existing "recording shorter than call span" diagnostic) — RF/decode, not the recorder; do not chase it from the recorder side. (3) Cyrillic recording folders as _______: the recorder sanitize was [A-Za-z0-9._-]; it now keeps Unicode L/M/N categories. (4) The floating AudioPlayer docks bottom-right exactly over DataTable's pager — pager is now rendered top AND bottom, and AppShell main keeps pb-24 at every width. (5) Per-RID playback: /calls/history?source_id= + a ▶ per recent call in the RID modal (RecordingPlayer inline).
  • "Host overruns at a tiny 200 kS/s" on the dual-TETRA wideband rig (10 Sep) was CPU, and the CPU was DecodeAACH re-ENCODING all 16 384 RM(30,14) codewords per call. DecodeRM3014Tetra / DecodeRM3014TetraSoft were ML searches that called EncodeRM3014Tetra (allocating) for every codeword on every call: ~15 ms and ~16 k allocations per AACH decode, run once per downlink slot (~70/s per carrier) on the wideband pump AND once per traffic burst in the voice demux. Two TETRA DDC channels at 200 kS/s measured 0.44x real time on one 2.1 GHz core with 65% in that search (TestEngineDualTETRA200kThroughput, GT_WB_BENCH_SECONDS=30 GT_WB_BENCH_PROFILE=...), and it was the ~19 GC/s on a 9 MB heap in the operator's heartbeat lines. Fix: a once-built codebook (rm3014Table, uint32-packed) with popcount / three 10-bit partial-sum tables — 17 µs / 36 µs, allocation-free, bit-identical (rm_30_14_tetra_codebook_test.go keeps the brute force as the reference). Second cost was filter.FIR.Process (~50% after that): its ring buffer branched per tap; it now runs a mirrored 2N history window with the SAME float32 summation order (TestFIRMirroredWindowIsBitExact), ~1.8x faster. Pump: 0.44x → 0.12x real time. Lesson: an "overruns at low rate" report on a path that logs no decode can't keep up WARN still means profile the pump — host_drops is the consumer, and the consumer here was a block decoder, not the DSP.
  • IPSC "still losing calls" (10 Sep, 1200 s Signal Lab flac at 442.3875 MHz) was the polyphase channelizer's BIN EDGE, root-caused on the capture and fixed by 2x oversampling the channelizer. The live wideband tap (6.25 MS/s, tuner_strategy: polyphase → 32 bins of 195.3125 kHz; Fire2 at +687.5 kHz = 3.52 bins ⇒ 0.48 from bin 4) logged sync_hits=0 for minutes at a time while the same capture decodes 25 001 idle beacons / 14 grants through a DDC (the repeater sits at −53 dBFS over a −83 dBFS floor ≈ 30 dB SNR, keyed solid through both missed windows — the offline TestDMRIPSCReplay grants at 12:17:27 and 12:20:34 local, the live log has nothing on Fire2 between 12:11 and 12:22). TestDMRIPSCWidebandReplay (GT_DMR_IQ=<flac> GT_DMR_WB=1, interpolates the slice to the field bin geometry and A/Bs ChannelizerBank vs DDCBank with one tap each on the SAME stream) reproduced it exactly: polyphase 1991 beacons / 2 grants vs DDC 6103 / 7 over 300 s, deaf in the same tens-of-seconds stretches. Mechanism: the critically-sampled bin's prototype is −6 dB AT the edge and the half of a 12.5 kHz channel that crosses ±binRate/2 folds back — measured −18.6/−9.3/−6.2/−5.2 dB across one channel's 0.45..0.51-bin span (TestChannelizerBankBinEdgeChannelIsFlat, fails on the old bank). channelizer.Oversampled (Harris M/2 polyphase: M/2 inputs per step, IDFT, and the per-bin e^{-j2πk·t_s/M} rotation a critically-sampled bank never needs) emits each bin at 2·Fs/M with cutoff 0.75·Fs/M, so the whole bin is flat; ChannelizerBank uses it unconditionally (fine-tune NCO/resampler now run from 2·binRate — dense-71 bench 1.5→2.8 ms/chunk, DDC 11.8 ms). Post-fix the polyphase arm matches the DDC arm window-for-window on the capture. The old "dense plan crowds taps onto bin edges — reduced SNR" WARN is gone (DEBUG layout line only). A clean synthetic C4FM carrier at residual 0.48 still GRANTED through the old bank (200 header repeats need only a few good bursts), so a synthetic grant test could not fail first — the tone-flatness pin + the capture harness are the regression; don't reintroduce a grant-based one.
  • P25 IMBE vs trunk-recorder/OP25 on the same 7-reply conversation (10 Sep, tg 805): measured, NOT changed. Merging GT's per-transmission WAVs back-to-back (TR's freqList.pos is cumulative length, no gaps) and aligning per segment: (1) GT's recordings are SHORTER — 27.18 s vs 30.42 s; the loss is whole LDUs at the HEAD (0.36 s = 2 LDUs on two replies, 1 LDU on one, 0 on three) — receiver acquisition, not the talkgroup gate (boundaryTracker already starts lastMatch=true); OP25 catches the first LDU more often. Needs a voice IQ capture to tune (ddaWarmupSymbols=512 ≈107 ms plus filter/AGC settle). (2) Spectrum: the SHIPPED WAVs (enhance chain on) sit at LSD 4.4 dB from OP25 (−5 dB at 100–300 Hz from the HPF/tilt, +3.6/+8 dB at 2–3/3–4 kHz); the raw recorder-default decode of the same .raw frames is 3.6 dB, and sweeping recordings.unvoiced_gain (TestIMBEUnvoicedGainSweep, GT_IMBE_RAW_DIR/GT_IMBE_GAINS) is monotonic: 5.49 (the dsd-neo/mbelib calibration) → 3.57 dB, 2 → 2.96, 1 → 2.69, 0.5 → 2.53. So the two references DISAGREE on unvoiced level by several dB (mbelib's 3-cosine unvoiced is ≈5.5x a voiced harmonic; the spec's equal-power rule is gain 1, which is where OP25 sits) — and the residual +5..+7 dB GT excess at 3–4 kHz (rel. 1 kHz) does NOT move with the gain, so it is in the high-harmonic spectral-amplitude reconstruction/§6.2 path, same family as the open AMBE 2450 note above; per-harmonic diff against OP25's imbe_vocoder is the next instrument. Don't flip the default on one conversation; an operator chasing OP25's balance can set unvoiced_gain: 1 and lower enhance.hpf_hz/tilt_hz. GT's per-call JSON shipped srcList: [] on this rig while TR lists the two radios (80902/80816); not investigated this round.
  • Capture ceilings: siglab capture-from-tuner 120→1200 s (maxCaptureSeconds, byte budget 4 GiB estimated at the UNCOMPRESSED decoder width even for flac), diversity_capture_seconds 120→1200 (maxDiversityCaptureSeconds); the REST hunt capture (/api/v1/hunt/capture) materialises the whole grab in RAM twice at the survey rate, so it got an explicit api.MaxHuntCaptureSeconds=60 instead. Capture NAMES use %.4f MHz (captureName) — %.3f renamed a 442.3875 MHz slice "442.387MHz"; every other frequency name in the tree was already %.4f, and toFixed(3) on a centre frequency in a SPA is a bug (sample rates / spans / kHz spacings at 3 decimals are fine).
  • AMBE+2 3600x2450 (DMR) is bit-identical to mbelib on every VOICE frame — the #644 "computer voice" was the SILENCE-frame handling, measured against two references. A frame-by-frame diff (szechyjs/mbelib 1.3.0 + arancormonk/mbelib-neo, both cloneable from the dev environment; build with gcc -c + ar, dump cur_mp per frame) of the committed internal/voice/ambe2/testdata/dmr-voice.raw (the reporter's 378-frame TS2 clip) shows unpackParams2450's Tl/Vl/L/w0 match mbelib to 1e-5 on all 201 voice frames and the amplitude prediction within ~1 dB per band — so the earlier "the 2450 high-band deficit is in unpackParams2450" note is REFUTED; do not chase the 2450 tables. What differed: 177 frames are AMBE+2 silence frames (b0 124/125, runs of 44/63 = ~1.3 s) that both references decode as an all-unvoiced fixed-model frame (w0 = 2π/32, L = 14, the frame's own ΔΓ/PRBA/HOC) and synthesise, while GT rendered digital silence and reset the predictor — so every post-pause onset frame had gamma = ΔΓ + 0 vs ΔΓ + 0.5·γ_prev (frame 95: 4.37 vs 8.40; frame 308: 5.78 vs 10.03 — ≈24 dB), and the pauses were hard-gated. Fixed to the reference behaviour; params2450_silence_test.go pins literal Tl (frame 0) and the gamma sequence at every onset (both refs agree to the printed precision). Two things the diff did NOT settle: the two references disagree on the silence model itself (mbelib-neo/JMBE uses L=15 and a different w0; mbelib and DSD-FME's lwvmobile fork use 2π/32 / L=14 — GT follows the DSD-FME lineage the reporter compares against), and the whole-file band fractions depend on that model, so compare voice frames only (voicemask) when measuring spectra. On this sample GT is NOT high-band deficient vs mbelib-neo on voice frames (3–4 kHz 0.057 vs 0.123 fraction, GT brighter).
  • P25 ADP (RC4, ALGID 0xAA, #1187) IS ON-AIR VERIFIED and decrypted in-process; the same reporter's DMR "RC4 audio" files were NOT air signals. The reporter posted three 96 kHz mono WAVs. The P25 one is a genuine C4FM discriminator tap (energy ≤ 3 kHz, RRC cliff): re-modulated through TestP25ADPReplay it yields 15 LDU1 + 15 LDU2 FEC-clean, every ES 0xAA / KID 1, and with key 1234567890 the IMBE pitch track goes 0.13 → 0.57 (speech). Two facts the capture pinned, both now in phase1/adp.go + testdata/adp_issue1187_ldus.json: (1) the keystream layout is OP25's — RC4(key‖MI[0:8]), drop 256, LDU1 voice from absolute byte 267, LDU2 from 368, +2 before u8 (LSD) — a brute-force over every base offset × MI choice found it unique (the LDU1 frames score lower on pitch continuity than LDU2's, 0.43 vs 0.64, but with ZERO out-of-range b0 across 126 frames, vs ~17 % under any wrong keystream — use BOTH statistics); (2) the ES in an LDU2 names the NEXT superframe's MI, and consecutive MIs follow the 64-bit LFSR x⁶⁴+x⁶²+x⁴⁶+x³⁸+x²⁷+x¹⁵+1 (8/8 and 14/14 pairs match), so RewindMI recovers the first superframe's MI without the HDU (which the LDU assembler never delivers) — the composer holds that one superframe (360 ms) until its ES decodes, then flushes. Applying the ES MI to its OWN superframe, or omitting the 11-byte skip, stays at ciphertext level (≤ 0.13). The two DMR files are white-noise bursts gated at an exact 30/30 ms slot cadence with a 15.5 kHz codec cliff, flat spectrum inside bursts, no 4-level eye, no symbol-clock line, not constant-modulus as interleaved IQ either — i.e. decoded/garbled audio re-recorded, not a discriminator tap; discriminatorAudioSanity in the harnesses now measures the ≤ 3 kHz energy fraction (92 % on the P25 file, 20–34 % on the DMR ones) and says so. DMR Enhanced Privacy therefore STILL awaits an on-air capture (IQ via gophertrunk capture, or a raw unsquelched discriminator tap).
  • DMR "Enhanced Privacy" (DMRA RC4, #1187) IS CAPTURE-VERIFIED (16 Sep): the reporter's two known-key discriminator captures decode to speech (b0 continuity 0.77 / 0.74), and the two defects that hid it were both on OUR side, not in the construction. (1) The harness sliced voice with the single-slot 132-dibit decoder; the radio is a simplex handheld (one burst per 60 ms, 288 dibits apart), so bursts B–F were read out of the inter-burst gaps — burst A FEC-clean, B–F with the random-word Golay signature (73 % exactly 3 corrections, 12 % exactly 2), which reads as "post-FEC scrambling" and is NOT. Same defect #1192 fixed in production the day before; the harness now defaults to the cadence-detecting decoder, and a C0/C1 Golay histogram is the first thing to check when frames look scrambled. (2) The embedded IV names the NEXT superframe's MI (the P25 ES convention): every superframe's IV equals AdvanceMI of the one before, the first equals AdvanceMI of the PI header's, and DSD-FME's order (LFSR advance in dmr_alg_refresh BEFORE the late-entry compare) says the same — EPTracker applied it to the CURRENT superframe, so the correctly sliced frames decoded on the wrong MI while the wrongly sliced ones decoded on the right one, and the two runs failed for opposite reasons. RewindMI (the LFSR is a bijection) gives late entry its first superframe. Also fixed: the harness's pitch metric read b0 as payload bits 0..6; AMBE+2's b0 is bits 0..3 + 37..39 (mbelib ambe3600x2450.c). Pinned by TestEPCaptureIssue1187 against voice/testdata/ep_issue1187_ptt1.json (literal on-air frames; old semantics fail) and GT_DMR_EP_DUMP is the offline instrument. Method that settled it in one pass: dump the raw frames, sweep the hypothesis space by b0 continuity (scratchpad sweep) — a Golay histogram + per-frame-index clean fraction told the slicing story before any cipher theory. STILL OPEN (#764/#771): the daemon path on air — a live call with encryption_keys configured recording intelligible audio is the reporter's confirmation. Earlier note, kept for the layout facts:** The reporter offered RC4 audio with a known key by email; the material must land on the issue (or a link) so the recipe is reproducible. What landed so the material can verify it: dmr.ParsePIHeader (12-octet PI header: alg / FID / KID / 32-bit MI / dest / CRC-CCITT with mask 0x9696 in GT's non-inverted convention = ETSI's 0x6969 inverted — the same relation as csbkCRCMask 0x5A5A ↔ ETSI 0xA5A5; pinned by a LITERAL vector from an independent Python CRC), dmrvoice.PIHeaderDetector beside the terminator detector, and dmrvoice/ep.go: RC4 key = key‖MI (4 bytes), drop 256 keystream bytes (the cryptolab dmrcrypto had claimed "no warm-up bytes" — unreferenced and wrong, fixed), 7 bytes per 49-bit frame contiguous over the superframe, MI advances per superframe through the LFSR x³²+x⁴+x²+1, silence-vector frames pass through in clear, and every superframe embeds its own MI (18 nibbles at on-air bits 71/67/63/59 of each frame = C3[0..3] after deinterleave — both refs agree, pinned through GT's own rW..rZ tables) as 3 Golay(24,12) codewords + CRC-4. EPTracker follows the chain (embedded IV confirms/overrides the LFSR prediction; a CORRECTED disagreeing IV holds the prediction because the radius-3 Golay sphere covers 57% of the space so a clear superframe "verifies" ~1% of the time — measured; the composer only acts on an embedded IV for a call with other encryption evidence or a perfectly clean decode). Composer: Options.KeyResolver (daemon builds it from encryption_keys), composer/dmr_ep.go publishes call.encryption for DMR, captures MI+ciphertext to crypto_capture_path, descrambles before the recorder. Failing-first: TestComposerDMREnhancedPrivacyDecryptsWithConfiguredKey (old chain records ciphertext). Harness TestDMREnhancedPrivacyReplay (GT_DMR_EP_IQ or DSD-FME-style discriminator audio GT_DMR_EP_AUDIO, GT_DMR_EP_KEY) — its verdict uses pitch continuity (|Δb0| ≤ 10 between consecutive frames: speech ≳0.5, random/ciphertext ≈0.16), NOT loudness: random AMBE parameters through the vocoder are loud, so an RMS "voice activity" verdict passed on a WRONG key (measured on the synthetic fixture) — never judge a decrypt by output level. Not decoded: Hytera EP (FID 0x68, 40-bit MI, vendor schedule), Kirisun, DES/AES in the voice path. SDRTrunk (Apache-2.0) is the layout reference; DSD-FME (GPL) was read for the conventions only, nothing ported.
  • #1187 follow-up (1 Oct): the daemon decrypt path is now checkable OFFLINE with the release binary — replay -key ALG:KID:HEX + replay -format disc — and doing it exposed that replay -record-voice DROPPED the start of every call. The reporter works in DSD+/DSD-FME and cannot run go test harnesses, and the harnesses never exercised the daemon's composer path anyway; replay built no KeyResolver, so an encrypted capture could only replay as ciphertext. -key reuses buildKeyResolver + EncryptionKeyConfig.Validate (factored out of system validation), ignoring the grant's system name; -format disc remodulates discriminator audio (any channel count, 8/16-bit or float WAV, 16-bit FLAC) to a temp f32 IQ file at the audio's rate — at amplitude 0.5, because unit-amplitude IQ tripped siglab's ADC-rail overload check ("25 % clipped") and handed the operator a false diagnosis. The replay defect: siglab decodes far faster than real time and replayVoiceSource.push discarded every chunk with no subscriber, so the chunks between the grant and the composer's chain subscribing — the headers, the PI header that NAMES THE KEY, the first LDUs, a whole short over — were lost; the source now keeps a 1 s pre-roll for each new subscriber and, once a grant is on the bus with no chain listening, holds the decode (≤5 s) until a chain subscribes. The grant is read off a bus subscription drained ON THE DECODE GOROUTINE at the top of each push (watchGrants; Bus.Publish enqueues synchronously), because the first version armed on CallStart via an async listener and PR #1234's CI (-race, loaded runner) lost the call: the decode raced past the pre-roll before the start arrived (TestReplayVoiceSourceHoldsOnGrantBeforeCallStart, failing-first). The same CI run's other half: the composer's hangtime and no-voice startup window (2× hangtime) are WALL-CLOCK, and the test's 500 ms hangtime let a starved chain be torn down before its first voice frame — tests of the replay voice path must use replayDefaultVoiceHangtimeMs (3.5 s), never a tight value. Pinned failing-first by TestReplayDecryptsRealAirEnhancedPrivacyFromDiscAudio: the committed real-air ciphertext (ep_issue1187_ptt1.json) re-framed into a keyup (noise → Voice LC Headers → PI headers → the three captured superframes with their EMB/embedded LC → noise), discriminated to a 96 kHz WAV, through prepareDiscInput + siglab + the replay rig: 54/54 frames clear with the key, 54/54 ciphertext without; 0 frames without the pre-roll. Fixture traps that cost a round each: (1) a repeating i&3 dibit filler carries a fixed symbol mean the AFC reads as carrier offset — use random dibits; (2) a stream that is carrier from sample 0 never arms the DMR receiver's feed-forward timing acquisition (it needs an absence first), so phases 1–5 of every 10-sample symbol never locked — model a keyup with noise before it, as a squelch-off recording has; (3) bursts B–F need the call's Full LC in their embedded fragments or the composer's slot router never binds the slot on a short call; (4) the disc sanity line measured over the whole file called a genuine tap with noise gaps "not a tap" (47 %) — it now uses the 95th percentile of per-20 ms-block fractions (92 % on the same file; the gated-white-noise shape of the reporter's first files still fails). The same test also caught a PRODUCTION data race under -race: VoicePool.Active() returned the pool's live *ActiveCalls, so runWatchdog (and the API's ActiveCalls()) read LastHeardAt unlocked while Touch wrote it per voice frame; both now return copies (every caller acts on a call through its device serial), pinned by TestVoicePoolActiveSnapshotIsRaceFree. Limit: replay records only what it can GRANT on the decoded carrier — conventional DMR grants itself, but the P25 pipeline grants only from CC TSBKs, so a P25 voice-channel-only recording (the reporter's ADP file) records nothing; TestP25ADPReplay or a live run covers ADP. STILL OPEN: the reporter's own run (replay -format disc … -key rc4:11:<key> on their files, or a live daemon call).
  • MDC1200 NEVER decoded a real radio in its whole life (#1220, 27 Sep): the line code is XOR-precoded MSK, and the receiver sliced the TONES as the data. The reference modem (Kaufman's mdc-encode-decode, GPL — read for protocol facts only, nothing ported) sends one cycle of 1200 Hz when a data bit equals the previous one and 1.5 cycles of 1800 Hz when it changed; the data is the running XOR of the tone decisions, and the sync word 0x07092A446F lives in the DATA domain. GT's afsk.feedSymbol pushed the tone bit straight into the sync hunt as "plain NRZ", and its tests encoded the same way — the #764/#771 self-consistent trap for four months, exposed only when the operator's Kenwood lab decoded FleetSync 8/8 through the IDENTICAL DSP chain and MDC1200 "never locked". Interleave (16×7), CRC (reflected CCITT, init 0, xorout FFFF, LSB byte first) and bit order were all already correct. Fix: zero-threshold tone decision → running XOR (r.data ^= 1 on 1800 Hz) → framer; the framer's complemented-sync accept absorbs the XOR's unknown start state (an inverted discriminator does NOT change a tone's frequency, so that is the only ambiguity). Pinned three ways: mdc1200/synth_test.go holds BurstBits/Precode to LITERAL 26-byte burst layouts and 208-bit tone strings produced by the reference encoder (the leader 0x55… is one continuous 1800 Hz tone on air, not 0101); afsk/airformat_test.go decodes the reference ENCODER's own 48 kHz audio (testdata/mdc1200_ref_01_80_1234_48k.s16) through ProcessAudio; and GT's synthesiser was decoded by the reference DECODER offline (both directions agree). All eight air-format tests fail against the old slicer (448 bits, 0 sync locks). Also learned: the reference's data[7..13] are convolutional parity bytes (taps 0,2,5,6 over the LSB-first header bits; fecParity), now generated so synthetic bursts match byte-for- byte — the decoder still does not use them for correction. ON-AIR VERIFIED (28 Sep): the reporter's live run on the fixed build decoded eleven PTT ID bursts (start 0x01/0x80 and end 0x01/0x00, unit 0x1777, 447.100 MHz, narrow and wide FM) through the scanner-channel path, and their 2.4 MS/s scanner voice recording replays through TestMDC1200Replay (carrier found at −1758 Hz, CRC-valid PTT ID (end)) despite being ADC-clipped at both rails (handheld in the room, the #836 lesson: clipped ≠ undecodable). A 0.7 s channelized slice is the committed real-air regression (afsk/testdata/mdc1200_unit1777_pttid_end_48k.cs16, realair_test.go). TestMDC1200Replay (GT_MDC1200_IQ/_RATE/_UNIT, same loader as the FleetSync harness) stays the instrument for the next capture. #1220 is closed on that confirmation.
  • FleetSync (#1184) IS ON-AIR VERIFIED (16 Sep, reporter's Kenwood lab: FS-I and FS-II both decode live) — and the live run exposed a coexistence failure that is NOT yet root-caused. Config: fleetsync.channels on RTL R1 + scanner.conventional on RTL R2 (systems=0, voice_devices=2). Each section alone works; together, 146 ms after start fleetsync: SetCenterFreq failed on R1 with the r82xx PLL I2C write EPIPE (tried chunk sizes 16,8,4; all stalled — the runtime control-pipe stall class of #248/#753), and at +627 ms the scanner opened a synthetic call on a SILENT 146.67 MHz channel that ran 23 s and ended reason=error at Ctrl-C. Two robustness holes turned one USB hiccup into "nothing works": (1) every single-channel decoder block gave up FOREVER on one failed tune/open — runSingleChannelDecoder (daemon.go) now retries tune / open / a decoder that stops with 500 ms→30 s backoff (MDC1200 + FleetSync converted; POCSAG/FLEX/M17/APRS/AIS/LoRa/DSC/ADS-B still have the give-up shape — same fix applies); (2) a scanner dwell whose IQ stream STOPS (without closing) never ended — hangtime counts silent chunks that never arrive, and the ticker kept touching the watchdog — so a wedged pump held a phantom call open; Options. StreamStallTimeout (3 s) ends it and Run re-opens the stream. Both pinned failing-first. Still open: WHY both dongles stalled at the same instant (a USB port reset on a sibling dongle sharing a hub is the #1135 lesson; the reporter's log excerpt starts at the banner, so the device opened / tuner-diag lines and whether the two NESDRs share a hub are the next instrument). Do NOT read the banner's rtlsdr[0] twice as a double-open: the pool snapshot dropped Info.Index (now carried as SDRStatus.Index). Also landed on request: FleetSync messages carry serial/frequency_hz (bus → fleetsync_log columns, migrated in place → REST → a Channel column). The reporter's design ask — attach MDC1200/FleetSync to scanner.conventional channels instead of pinning a whole SDR — is sound (the dwell already has the FM chain; an FFSK decoder could hang off the dwell IQ) and NOT built. Earlier note (offline verification), kept:** FleetSync (#437/#1184) is VERIFIED OFFLINE on the reporter's two SDR# captures — and the blocker was never FleetSync, it was the WAV reader. internal/radio/fleetsync/afsk mirrors the MDC1200 front end (FM → resample → 1200/1800 Hz demod.FFSK → Mueller-Müller → slicer → fleetsync.Framer), with the baud rate as an option. Lesson that cost a round: slice FFSK at a fixed ZERO threshold (as the reference does) — an FS-II frame whose word1 nibbles are small opens with ~68 symbols of continuous space tone, a 1/512 bias tracker drifted toward it and flipped the ISI-weakened isolated bits after the run (23 errors/frame → 0). Result on the captures (2.048 MS/s SDR# baseband WAVs, VFO 462.5625 MHz, Fleet 107 / Unit 1772): FleetSync-I 5/6 ANI bursts CRC-valid (one RF miss at t=3.0 s), FleetSync-II 8/8, raw words FE80083053059B6C / FC80083053057E59 repeating — through the production front end, one command per file (GT_FLEETSYNC_IQ=<wav> go test ./cmd/gophertrunk -run 'TestFleetSyncReplay$' -v). Three things had to land first, each a trap for the next capture: (1) every IQ WAV reader assumed 16-bit PCM — siglab.DecodeContainerFile stripped 44 bytes and read the SDR# 32-bit FLOAT body as int16 pairs (2× the samples at −2.5 dBFS of noise; the harness "signal stats" line is what exposed it: a real capture does not sit at −2.5 dBFS with a 0.16 DC), and baseband.parseIQWavHeader / ReadIQWavStreamHeader rejected anything but 16-bit. IQWavInfo.Encoding (pcm16 / pcm8 / float32, WAVE_FORMAT_EXTENSIBLE unwrapped, chunks before data skipped) now drives the replay driver, UnwrapContainer, DecodeContainerFile and siglab's prepareWAVInput; pinned failing-first by wav_encoding_test.go + container_encoding_test.go. (2) An SDR# baseband recording is the whole tuner span, not the VFO: the signals sat at +424.5 kHz and +323 kHz off centre (different in each file — SDR# recentres per recording), so a run at centre decodes nothing; the harness now searches a whole-capture averaged 4096-point spectrum (fleetSyncCarrierCandidates) and tries each carrier, strongest first. (3) The shared dsp.EstimateCarrierCandidatesHz scans only its first ~256 short windows — tens of milliseconds — which on these captures is before the radio keys up: it ranked a −423 kHz spur first and never saw the 64 dB FleetSync carrier. It is right for a continuous control channel and wrong for a bursty PTT capture; average over seconds for those (not changed in the shared estimator — siglab -auto-tune callers should know). Real-air regression: TestFleetSyncRealAirSlices (afsk/testdata/fleetsync{1,2}_fleet107_unit1772_48k.cs16, 1.3 s channelized slices of the reporter's bursts). The daemon wiring LANDED (the MDC1200 shape, one file per layer): fleetsync.channels config → afsk.Options.Bus publishes KindFleetSyncMessage / storage.FleetSyncMessage (the front end publishes, the protocol framer stays callback-only so it unit-tests alone) → storage.FleetSyncLog (fleetsync_log, in the retention sweep) → GET /api/v1/fleetsync/messages → /fleetsync panel + config-builder section + web.tabs.fleetsync. Adding a decoder like this touches ELEVEN places and three tests police them: TestFieldHelpCoverage (fieldmeta), TestConfigSchemaCoveredByWebBuilder (configbuilder types.ts) and the web registry.test.ts / App.panels.test.tsx route lists — plus the doctor preflight "needs storage.path" list and config.example.yaml (the 4 Sep rule). The bus path is pinned on the real-air slice (receiver_bus_test.go). Still open: the reporter's live lab test on the Kenwood radios (#764/#771 — synthetic + offline ≠ on air).
  • 15 Sep IPSC field material (X310 6.25 MS/s wideband, Fire/Fire2 taps, 60 s "442.8125 MHz" cs16 + debug.log): four defects, all pinned, and the −20 kHz mystery is bounded, not solved. (1) The siglab capture was carved at the TUNER centre (441.7 MHz), not the 442.8125 MHz the operator typed — the file's only carriers sit at −11/−37/−61/−87 kHz and +213 kHz and NEITHER repeater is in it; the start log line said center_hz=441700000 tuner_center_hz=441700000 and nothing in the UI disagreed. The daemon and the SPA at the operator's commit both wire center_hz correctly, so the centre was lost client-side: the form coerced with Number() and silently fell back on NaN (a decimal comma / a pasted invisible character), and a centre without a bandwidth was dropped the same way. Now: web/siglab/src/lib/captureTuning.ts parses strictly and errors, the route 400s a centre-without-bandwidth that differs from the tuner centre, the start line carries requested_center_hz, and the staged capture row shows its centre. Ask the operator to re-capture 442.3875 MHz with a ±100 kHz bandwidth (see (4)). (2) FLAC aborted the same grab with "unable to encode sample rate 880029": the FLAC FRAME header can only carry ≤ 65535 Hz verbatim, ≤ 655350 Hz in tens of Hz, ≤ 255 kHz in whole kHz — an odd rate above 65535 (the DDC's capped L/M gives 880029 for an 880 kHz slice of 6.25 MS/s), and ANY rate above 655350 (a clean 880000 too), needs the "get from STREAMINFO" code, which mewkiz/flac never picks on its own. baseband.FLACFrameSampleRate (shared with voice.FlacWriter) stamps 0 there; FLACMaxSampleRateHz (20-bit STREAMINFO, 1048575) is refused at construction (the upstream encoder silently writes the low 20 bits otherwise) and the capture route 400s a full-band flac over it before pinning the tuner. (3) The Fire2 deaf-heal WARN fired every ~15 s (21 in 6 min) with coarse_offset_hz≈−20100 on a GPSDO X310 that decodes at 0 Hz: the repeater beacons in ~10 s idle-burst trains with ~5–9 s gaps; in the gap the tap's power stays at the train level (−49 dBFS — Fire's drops 5 dB, so this is NOT the repeater's carrier) and the coarse carrier acquirer, which is a discriminator MEAN with no bound and no decode check, froze on whatever dominates the tap at −20.1 kHz ± 150 Hz, deafening the channel until the heal reset it — every gap. By frequency alone the stage cannot tell a neighbour from a 45 ppm tuner error (#836), so decode evidence now settles it: healDeafTier2 confirms an offset that held for a whole synced window and REJECTS an engage the channel never synced under (Receiver.RejectCoarseCarrierOffset → coarseCarrierAcquirer.Reject: reverts the live correction, ignores candidates within 500 Hz, survives Reset). Pinned by coarse_reject_test.go + the TestDeafHeal* family. The heal WARN is now once per 10 min per channel (DEBUG after; deaf_heals counts). Also fixed in that path: the beacon delta was computed AFTER ec.lastCnt = c (always 0, latent), and Counters.Dibits was never incremented (every activity line read dibits=0 on a decoding tap). (4) What the −20 kHz emitter at ≈442.3675 MHz IS remains unknown — the 15 Sep capture does not cover it (441.26–442.14 MHz). Geometry rules out the channelizer (bin-4 image of Fire lands at +68.75 kHz, the bin centre at +93.75 kHz, the tuner DC nowhere near). A capture centred on 442.3875 MHz with ±100 kHz over one full idle cycle (train + gap) is the instrument; TestDMRIPSCReplay plus a gap-phase spectrum will name it. The reject path makes the tap immune either way; do NOT add a frequency bound to the acquirer to "fix" this — a 12.5 kHz adjacent channel sits inside any bound that still serves #836.
  • 16 Sep IPSC "still deaf" (Fire2, some calls now captured): the DMR receiver had NO channel-select filter — the 48 kHz channel stream passes ±~22 kHz (two 12.5 kHz channels either side) straight into the FM discriminator, and an FM discriminator cannot separate co-passband carriers of comparable power. The 15 Sep fix (reject a coarse-acquirer engage the tap never synced under) stopped the acquirer LATCHING on the −20.1 kHz emitter, but while that emitter is up at the tap's own level the discriminator output is garbage regardless — that is the residual deaf stretch. dmrrx.Options. EnableChannelFilter (Kaiser lowpass, ChannelCutoffHz 6250 = half the channel spacing, 181 taps at 48 kHz, ~1.5 kHz skirt, >80 dB on the 12.5 kHz neighbour) now runs after the coarse de-rotation and ahead of the discriminator on EVERY production DMR receiver (widebandt2 Tier II/III, ccdecoder Tier I/II/III, the composer voice chain, the symbol scope). Failing-first: TestReceiverChannelFilterRejectsCoPassbandNeighbour (equal-power C4FM neighbour at −20 kHz: 0/40 syncs unfiltered, 40/40 filtered). Two traps the filter sprang, both pinned: (1) the carrier gate's thresholds are absolute rad² measured on WIDEBAND noise (uniform ±π ⇒ 3.3 rad²); filtered noise swings ~0.49 rad², so the fixed 1.0/2.5 thresholds read every gap as a quiet carrier and the gate never closed — the #836 direct-mode fixture decoded ZERO syncs. newCarrierGateCalibrated sets the thresholds as FRACTIONS of the noise-only variance, which is MEASURED at construction by pushing fixed-seed white noise through the actual filter taps (filteredNoiseDiscVariance) — no closed form for the wrapped variance of narrowband-noise instantaneous frequency. (2) The fractions are NOT the wideband ratio: filtered noise is correlated over ~4 samples, so the 4-symbol EMA swings wider (gap minimum 0.14 vs median 0.47) and a 0.30 fraction flickered the gate open on gap noise; 0.15/0.75 hold. That slower open (~2.3 windows) then muted the first 4–8 dibits of every burst behind a 1-window delay line and BPTC mis-corrected the Voice LC Headers (the direct-mode pipeline fixture lost a whole PTT) — the calibrated gate's delay line is sized to the open latency and a close-HOLD keeps the burst tail out of the mute. The wideband (unfiltered) gate path is byte-identical. STILL ON-AIR-GATED (#764/#771): the operator's next Fire2 run decides; if the −20 kHz emitter is a real 12.5 kHz-adjacent DMR channel the filter is the whole fix, if it is closer than ~7 kHz it is co-channel and nothing short of moving the antenna helps. The offered Hytera scrambler capture is a separate item (DMR "basic privacy" scramble, not RC4).
  • recordings.voice_profile (mbelib | op25) is the opt-in the 10 Sep P25 A/B pointed at: the two references disagree on the unvoiced level by several dB (mbelib 5.49× vs OP25's equal-power 1×) and on the LF tilt, so "sounds like trunk-recorder" is a preset, not a fix — op25 sets unvoiced_gain 1 and (with enhance on) tilt off / hpf 100 Hz, filling only knobs left at default (RecordingsConfig.ResolveVoiceCalibration, decode -voice-profile). What it deliberately does NOT touch: the +5..+7 dB 3–4 kHz excess (in the high-harmonic reconstruction; needs the per-harmonic OP25 diff) and the whole-LDU head loss (needs a voice IQ capture).
  • Sample-synchronous multi-frequency capture landed (centers_hz on POST /api/v1/siglab/capture, comma list in the SigLab form, capture -centers): every slice is carved from the SAME live chunks through an identical DDC (same rate + bandwidth ⇒ same group delay), so sample N of each file is the same instant — pinned by keyup-onset equality across slices (TestSiglabCaptureMultiSliceIsSampleAligned, TestCaptureToFilesSlicesAreSampleSynchronous). One shared bandwidth is the invariant (different bandwidths ⇒ different group delays ⇒ not aligned); sidecars carry capture_group / index / size / capture_started_at. Up to 8 slices; each is a full polyphase DDC on the capture goroutine, and the broker drops chunks to a slow consumer.
  • 17 Sep IPSC "Fire2" 600 s capture (442.3875 MHz, 25 kS/s flac + debug.log): three MORE defects beyond the channel filter above — a deaf-heal that false-fires every idle gap, a re-key that drops both overs' recordings, and a late first grant. Same tap as the 16 Sep note (idle-beacon repeater, cc 12, tg 11 src 199, ~10 s beacon trains / ~5–9 s gaps, the −20 kHz neighbour). With EnableChannelFilter on the CC taps the neighbour is gone; these three are independent, each pinned failing-first:
    1. Deaf-heal false-fires every idle gap (7 heals in 3 min). healDeafTier2's decoding-level reference (decodeDbFS) took the mean power of the ~1 s window that STRADDLES a train's tail — a few syncs over mostly-gap samples reads −63/−61 dBFS — as the channel's level, so the next gap windows (−65 dBFS, inside the 6 dB margin) read "deaf at its decoding level" and reset a healthy receiver. Fix: the reference rises to any synced window at once but falls by at most tier2DeafRefDecayDb=1 dB per synced window, so a single edge window cannot drag it into the gap while a genuine level change still tracks within a few windows. Pinned by TestDeafHealIgnoresTrainEdgeWindow / TestDeafHealTracksGenuineLevelChange (engine_deafheal_test.go).
    2. A re-key within hangtime dropped BOTH overs' recordings. The composer runs the recorder drain-coordinated, so on a Voice-LC-Header re-key (the engine ends the previous call and grants the next in the same instant) the previous call's CallEnd is deferred waiting for the chain's drain signal when the next CallStart lands. handleStart's "device already has session, replacing" path then closed the previous session WITHOUT finalizing it (no CallComplete, no sidecar, no history row), and the previous call's late drain signal finalized the NEW session before its first frame (files open lazily, so silently) — every frame of the next over dropped. The field log's lone "replacing" WARN is exactly this. Fix: finalizeDrainingBeforeReuse finalizes the deferred previous call with its own CallEnd before the reuse, and every finalize/drain path is now fenced by Grant.CallID (callIDsDiffer, NotifyDrainCompleteForCall threaded composer→fanout→recorder) so a stale drain can neither finalize nor pre-drain the next over. Pinned by TestRecorderRekeyDuringPendingDrainKeepsNextOver (old recorder records one over and emits one CallComplete; fixed records both).
    3. The first grant slid 3.4 s late — the channel filter fixes it; a false timing seed was a smaller contributor. In the offline 600 s replay the FIRST grant moves 3.41 s → 0.85 s with the channel filter alone (GT_DMR_NO_CHANNEL_FILTER=1 puts it back to 3.41 s): the cold-start receiver sits through the opening idle gap where the −20 kHz neighbour engages the coarse acquirer, and the filter keeps that from displacing the first keyup's header decode. Separately, the #836 feed-forward timing acquisition seeded the loop from a SINGLE eye-estimator window; the repeater keys up with ~40 ms of near-unmodulated carrier, and a window straddling that transient could seed a phase ~2 samples off and displace the loop's still-valid held phase. The seed now requires TWO consecutive windows whose symbol instants agree within timingAcqAgreeSamples=1 (mirroring the coarse acquirer's two-windows-agree rule); after timingAcqMaxWindows=4 disagreeing windows it gives up and the loop pulls in on its own, as before. Its measured effect on this capture is small (superframes 215→216, late_entries 2→1) and it is byte-identical on continuous streams. Real-air smoke test dmr-ipsc-442.3875-keyup-17sep-48k.cs16 (TestReceiverRealAirKeyupDecodes) pins that the production CC config decodes this keyup's header train. Full-replay net: 17→17 grants, first grant 3.41 s → 0.85 s, superframes 213→216. STILL ON-AIR-GATED (#764/#771): the daemon-path A/B (does the live tap stop the every-gap heals, does a re-key now record both overs, does the first PTT of a train grant on its header) needs the operator's next live run on a build with these fixes.
  • Analog-FM "noise/tone on an on-frequency signal" (#1184, 23 Sep) = zero-IF tuning of an overloaded ADC; the conventional scanner now offset-tunes. The fingerprint to look for first: per-axis ADC clipping squares the constellation (|IQ| ∈ [1.0, 1.414] in the capture), and the discriminator then emits a tone at 4× the residual carrier offset — the reporter's Kenwood captures carried it 18–25 dB above the voice floor at exactly 4δ; the unclipped Radtel did not. The earlier "all six captures decode cleanly" conclusion missed it because nobody checked the rail fraction or searched the audio for a 4δ line. Fix: convScannerFrontEnd (cmd/gophertrunk/conv_offset.go) tunes the LO below the channel and NCO-mixes back for the scanner AND the FM chain (convScanVoiceSource), at an offset SEARCHED so DC spur / image / clipping products (4k·offset mod fs) miss the channel. Never use fs/4 for a clipped signal: 4·(fs/4) ≡ 0 mod fs, the 3rd-order product aliases onto the channel (simulated SINAD 0.5 dB, same as on-channel; searched 518 kHz → 56 dB) — and fs/4 is the dc_avoid default for control and voice SDRs, which has the same exposure (not changed here). Still on-air-gated.
  • macOS USB errors come in two IOKit families, and only the common one was mapped (#1135, 29 Oct). kIOReturn* codes are 0xE00002xx; the USB family's are 0xE0004000 | x (IOKit/usb/USB.h): kIOUSBPipeStalled 0x4f, kIOUSBTransactionTimeout 0x51. A raw IOKit kern_return 0xe00040xx in a log is an UNMAPPED code, so every retry keyed on a sentinel (isBringupResetable, the in-place control retry) silently skips it. The mapping (usb/iokit_status.go) has no build tag on purpose so Linux CI tests it. Control requests now go through DeviceRequestTO (v182 device interface, vtable 30) with the caller's 300 ms timeout. The base DeviceRequest has none and hung for the kernel's ~5 s default, exactly sdr list --probe's deadline. On-air-gated: the reporter's Mac.
  • The #1135 "probe never resets" rule is a macOS/usbdevfs rule, not a universal one (#1200). It exists because an IOKit/usbdevfs reset re-enumerates the device and perturbs siblings on the same controller. A WinUSB Reset only re-opens this process's handle, so on Windows a single-pass probe just discarded the recovery the daemon Open gets for the same transient (the reporter's intermittent first-write USB_SYSCTL timeouts). Transports now declare usb.LocalResetter; probeAttemptsFor gives local-reset transports a 3-pass budget that fits the 5 s probe deadline. The root cause of those WinUSB first-write timeouts is still unknown: the reporter's traces are GitHub attachments, which this environment's proxy refuses, so get them pasted inline.
  • The #1184 tone gates, round 2 (24 Sep): three more defects, all failing-first. (1) toneout.NewGoertzel ROUNDS to the nearest bin — fine for paging tones, fatal for CTCSS, whose EIA tones sit 2.3–3.0 Hz apart: at 5 Hz resolution "162.2" was a 160 Hz bin, so 162.2 stayed shut and 159.8 opened (the reporter's exact report). CTCSS now uses NewGoertzelExact + 250 ms blocks + the adjacent EIA tones as reverse bins; 150.0/151.4 (1.4 Hz apart) cannot be separated and open together. (2) The fixed 5e-4 magnitude was ~540 Hz of tone deviation; NFM radios send ~350, so it is now derived from ctcssMinDeviationHz. (3) DCS was the self-consistent trap again: codeword layout AND bit order invented, and synthesizeDCSIQ sent the same invention. On air bit 0 goes first: C0..C8, 0 0 1, P0..P10 (023 = 0x763813). Pinned against the published parity equations (independent of the encoder) and SDRangel's alias tables (023≡340≡766, 023N≡047I). All three are now ON-AIR VERIFIED (24 Sep, reporter's Kenwood NX-300/NX-5000 + service monitor), and the run pinned the DCS polarity the nrz_inverted= log was waiting for: D025N → false (1 bits = positive deviation), D025I → true. The gate therefore accepts only tone.dcs_polarity (normal default | inverted | both); the other sense is tracked only to WARN once per channel on an N/I mismatch. Aliasing is inherent: a normal 023 gate still opens on 047I (same bits) — do not "fix" that.
  • MDC1200 / FleetSync on scanner.conventional channels (#1220) run behind the scanner's OWN channel front end, never on its raw IQ. conventional/data.go: decoders: [mdc1200, fleetsync] on a scan-list entry → Options.DataDecoders (daemon factory conv_decoders.go, same afsk receivers + bus kinds as the dedicated-SDR sections, stamped scanner serial + channel frequency). dataFrontEnd integer-decimates to ~48 kHz (an m that divides the SDR rate so the decoders' L/M resamplers stay small: 2.4 MS/s→48 k, 2.048 MS/s→51.2 k) behind a ±8 kHz channel filter. Measured on the #1184 real-air FleetSync slices at 2.4 MS/s with band-wide noise at 0 dB: raw IQ into the receiver 0/2 bursts, through the front end 2/2. A decoder that has locked sync (Busy) extends the scan window (≤500 ms) and counts as dwell activity (hangtime can't cut a burst); the framers are Reset on every retune so a cut-off burst can't frame the next channel's bits. Decoders ignore the CTCSS/DCS gate by design. On-air-gated (#764/#771): the reporter's Kenwood lab run is the confirmation.
  • AM on the conventional scanner (#1219): mode: am → protocol am-conv → the FM chain with am=true. Composer: ±4.5 kHz channel filter, demod.AM (envelope ÷ tracked carrier − 1 = modulation depth, so the carrier IS the AGC reference — level-independent), 3 kHz voice LPF, amAudioScale 0.4 to sit at NFM level; de-emphasis and the CMA equalizer are skipped (CMA's constant-modulus target is wrong for AM). Scanner: AM channels squelch on amCNMeter (Welch PSD at ~48 kHz: strongest bin within ±4.5 kHz over the 20th-percentile floor within ±12 kHz), NEVER dBFS — noise reads 2.6–7 dB at any level, a carrier tracks its true C/N ±1–2 dB, default open 12 dB. Test gotchas: the chain's default naive PCM decimation restarts its phase every IQ chunk, so a coherent tone measurement needs AudioResampler{Enabled:true}; and two carriers in one discriminator beat, so an "FM chain can't hear AM" counterfactual must use a lone carrier. On-air-gated (#764/#771). First on-air captures (28 Sep, reporter's own aircraft radio, the composer's voice_iq_debug .cs16 at 2.4 MS/s — the file name carries channel + rate): the reporter's "tunes best at 123.453, not 123.450" was NOT a GT frequency shift. The capture is the chain's input (convScanVoiceSource already mixed the LO offset out), and the carrier sits at −3315 Hz of 123.453 ⇒ the transmitter as this SDR sees it is ≈123.4497 MHz; 123.450 was right. What the offset DID expose: the AM channel filter was centred on the TUNED frequency, so a few kHz of ppm/tuning error cut the far sideband (−6 dB above ~1 kHz). amCarrierAFC (am_afc.go) now finds the carrier (FFT peak within ±4.5 kHz, ≥15 dB over the window median, holds on noise) and mixes it to DC ahead of the filter; as-captured vs recentred audio correlation 0.954/0.886 → 1.000/0.999 (TestAMCaptureReplay, GT_AM_IQ). Also measured: 60–83 % of the decoded energy is below 300 Hz (the reporter's hangar AC interference) — production's default 300 Hz fm_audio_highpass_hz applies to AM too; the harness runs without it. The 127-tap 3 kHz AM audio LPF has a ~2 kHz skirt (−2.4 dB at 2.5 kHz) — not changed. AM IS ON-AIR VERIFIED (29 Sep): on a build with #1227 the reporter heard "very good and clear" audio, on frequency, with no extra noise or artifacts. Their 126.400 MHz KRIC capture (carrier 0–1.5 s, receiver noise after unkey) is now two 48 kHz real-air slices in scanner/conventional/testdata/am_kric_*, pinning the squelch (carrier C/N 47–61 dB, noise 2.7–6.1 dB) and the carrier tracking. Measure a tuning-error regression in the 1–3 kHz band, NOT by audio correlation: that capture is ~84 % hum below 300 Hz, so the pre-#1227 chain still correlates 0.995 with a 3.3 kHz error while losing 4.1 dB above 1 kHz (TestAMChainRealAirTuningErrorKeepsAudio). The reporter's follow-up ask, ACARS decode off the scanner (like the MDC1200/FleetSync taps), is not built.
  • ACARS (#1231) decodes off a scanner AM channel (mode: am, decoders: [acars]) — verified against acarsdec in BOTH directions, live run still pending. The reporter's capture (rtl_fm AM audio at 12.5 kHz + acarsdec's text decode) is a GitHub attachment this environment's proxy refuses, so the real-air pin is acarsdec's own test.wav (4-channel 12 kHz AM audio, 7 messages): GT decodes all 7 to acarsdec's fields, and acarsdec decodes receiver.SynthAudio. Literal on-air blocks from it pin the parser (acars_test.go); the WAV itself is GPL repo content and is NOT committed. Lessons: (1) the line code was settled empirically, not from a spec: of the four tone/line-code mappings acarsdec decodes only "2400 Hz = data bit equals the previous one, 1200 Hz = it changed" (MDC1200's construction with the tones swapped). (2) A tone-deciding (FFSK discriminator) front end is the wrong tool for this MSK: data = running XOR of the tone decisions, so one tone error inverts the REST of the block (measured: one error at byte 11 complemented 80 bytes; 5/7 decoded). The coherent detector (receiver/msk.go) reads each bit from the absolute phase at the bit boundary after de-rotating by k·π/2 — an error stays one bit — and decodes 7/7, matching or beating acarsdec at every white-noise level added to test.wav. Its timing loop runs Gardner on the ONE-BIT phase difference (the per-sample frequency slipped bits under noise) and drops its gain once the framer locks; the 128-bit pre-key is one steady tone and carries no timing information, so pull-in happens in the sync characters. (3) A 16-bit check over many repair candidates validates garbage: three parity errors give 512 candidates (≈0.8% false accept); repairs are accepted only when unique AND printable — 70/20 000 parity-valid garbage blocks validated without the guards, 0 with them. (4) acarsdec stalls on digitally-silent leading audio; give synthetic files a noise floor before reading a miss as a format difference. Gate (#764/#771): the reporter's own file through GT_ACARS_AUDIO=<wav> go test ./cmd/gophertrunk -run TestACARSReplay -v, then a live scanner run. Known limit: an ACARS burst also opens an ordinary AM "call".
  • A DMR scan-list channel's squelch is now a CALL gate, not a power gate (#1261 follow-up). A keyed-but-idle repeater (Idle bursts / CSBK beacons) held the conventional scanner on its channel. conventional.DMRCallDetector (dmr_gate.go, the NAC-gate shape) runs the production DMR receiver on the channel front end and is present only within 1 s of a Voice LC Header / PI header / voice-burst sync; the polarity is locked from clean slot types like the Tier II slicer (data and voice syncs are each other's flip, so an inverted beacon reads as voice syncs until then — a corrected slot type before the lock returns, and a pre-lock voice sync needs a second within the hold). Gotcha that cost a round: at 2.4 MS/s the DMR receiver started at an arbitrary sub-symbol phase decoded 0/60 syncs at some phases (the loop pulls in over seconds) — Receiver.ArmTimingAcquisition() (opt-in, the #836 feed-forward acquisition armed without a prior absence) gives 59–60/60 at every phase. A power-open DMR channel's scan window is extended once to 500 ms to wait for evidence. Synthetic-verified only (#764/#771).
  • Scan-list DMR direct mode was cut into hangtime-sized calls (#1261, 14 Oct field run). The conventional scanner judged dwell activity chunk by chunk on in-channel power, and a simplex DMR handheld is OFF for 32.5 ms of every 60 ms frame, so every gap started hangtime and no 27.5 ms burst could outlast the 50 ms ActivityDebounce to stop it: one over became a string of 1.5-2.5 s WAVs (the reporter's 10 s call on the same channel was src 9990, the Parrot through a repeater, i.e. a continuous carrier). A DMR channel now bridges gaps shorter than dmrBurstBridge (100 ms) after its last above-squelch chunk; analog keeps the #1090 debounce. Same run: conv: StreamIQ failed … rtlsdr: stream already active was the purego RTL driver failing a reopen while the cancelled stream's teardown goroutine was still in StopBulkIn (the airspy family's #686); StreamIQ now waits out a stream whose ctx is already cancelled, and still refuses one whose ctx is live. Also: the NXDN receiver is NXDN96 (4800 sym/s) only — 6.25 kHz NXDN48 is not decoded at all, which the docs used to deny. On-air-gated (#764/#771).
  • Scan-list loudness (#1239 follow-up): digital and analog are levelled differently — balance per channel with audio_gain_db, don't move a global default. The IMBE/AMBE vocoder AGC targets a fixed peak (18000, ≈−5 dBFS; measured −19.5 dBFS RMS on imbe/testdata/p25p1-tone-bracketed-voice.raw), while the analog chain has NO AGC in production (AudioAGC is never configured) and scales the discriminator ×10 000, so a full ±2.5 kHz NFM channel peaks near −20 dBFS: by default P25 is ~10 dB LOUDER than NFM. A report of the opposite is source/config-specific (5 kHz-deviation amateur FM, compressed air-band AM, enhance/normalize settings), which is why the fix was a per-channel knob, not a default. The gain rides Grant.AudioGainDB: the composer's FM/AM chain applies it before PCM conversion, the recorder applies it to decoded digital PCM (the only place digital PCM exists) — never both, as analog never reaches writeRawFrame.
  • The conventional scanner's FM squelch measured the WHOLE SDR span, not the channel (#1239). squelch_dbfs was PowerDbFS of the scanner's full 2.4 MS/s IQ, so any other carrier in the span (or the noise floor an auto-gain tuner holds near full scale) kept every channel "open": untoned channels opened calls on empty air and toned channels were gated by the CTCSS/DCS detector alone ("Tone only, not CSQ AND Tone"). FM channels now squelch on channelPowerMeter (the tone front end's decimate + ±8 kHz filter, then RMS), which reads the same level as before for an on-channel signal. It is still absolute dBFS, so a big gain change still moves it; the gain-independent answer is a noise-quieting squelch (the dmrrx.carrierGate discriminator-variance idea), not built. Per-channel gain landed in the same PR for mixed-band scan lists.
  • A static GOOS=linux binary is NOT an Android binary — the Termux builds are GOOS=android (#1230). v1.2.3 shipped static GOOS=linux Termux tarballs (-tags nolibasound to drop purego's libdl, which a Linux build links even with cgo off) and they died before main with SIGSYS: bad system call on an S5 (LineageOS) and an Android 14 phone: Android puts every app under a seccomp filter that answers faccessat2 with SIGSYS (bionic SECCOMP_BLOCKLIST_APP.TXT), and Go's syscall.Faccessat tries faccessat2 first on GOOS=linux and skips it ONLY on GOOS=android. os/exec.LookPath reaches it whenever the program EXISTS, and atotto/clipboard's init (via bubbles/textinput) looks up termux-clipboard-set — so it crashed exactly on phones with Termux:API installed (the S5 Neo without it ran fine; the crash register's 56-byte path is that file). Reproduced on a Linux host with a seccomp launcher trapping syscall 439 (only crashes once a fake termux-clipboard-set is on PATH). Other LookPath callers (colorprofile's tmux info, -web's xdg-open, alerts exec) would hit it too, so patching callers is whack-a-mole and a -overlay of the stdlib is refused when GOROOT is in GOMODCACHE. Fix: scripts/build-android.sh builds GOOS=android with cgo against the NDK (android/arm cannot link internally; API 21), linking only libc/libdl/liblog via /system/bin/linker{,64} and getting bionic DNS for free; scripts/check-android.sh fails the build unless the interpreter is Android's, NEEDED is that allowlist, and go tool nm shows no syscall.faccessat2 (failing-first: it rejects the v1.2.3-style binary on both counts). CI's termux-static job and both release workflows use the runner's preinstalled ANDROID_NDK_HOME. Not runnable on a CI host, so a phone is still the on-air gate. gophertrunk power (internal/powersweep) is the rtl_power-compatible sweep logger the same issue asked for; its rtl_tcp path is pinned by TestPowerSweepOverRTLTCP (fake rtl_tcp server). On the reporter's 32-bit phones (8 Oct) the per-sample complex128 FFT ran BELOW real time (~1.3 of 2.4 MS/s; 38 MS/s on an x86 dev box), so each hop waited for its full sample share and a -i 30 sweep took ~1 min while the borrowed hunt IQ reader flooded hunt: decode falling behind. Hops now carry a wall-clock budget (powersweep.Integration.Budget = interval/hops − settle) and the CSV samples column is the honest count; power notes it once (quietDrops silences the hunt WARN). The FFT was then rewritten (fft.Radix2, complex64, bit-reversed write in the window step): 3.3× on amd64 and 3.8× on GOARCH=386 at 1024 points. The single biggest factor on 32-bit was NOT the algorithm: Go evaluates a complex64 product in float64 (widen/narrow around every multiply), so the butterfly is spelled out in float32 — the plain radix-2 with complex64 * was only 1.5× on 386. Keep that in mind for any hot complex64 loop. Not verified on an ARM phone yet.
  • A detector threshold in radians-per-sample is a SAMPLE-RATE trap (#1184 CTCSS). The conventional scanner's CTCSS gate never opened on air: its Goertzel threshold was calibrated on 48 kHz unit tests, but the scanner feeds 2.4 MS/s, where the same deviation is 50x smaller in rad/sample (≈3000x in power). Unit tests at 48 kHz with a clean synthetic tone could never catch it (the self-consistent trap again). CTCSSDetector now decimates to ~48 kHz, channel- filters ±8 kHz and normalises to the calibration rate; ctcss_realair_test.go interpolates the reporter's real-air 48 kHz slices back to 2.4 MS/s so the PRODUCTION rate is what's pinned. The DCS detector also demodulates the raw band but slices by sign (rate-invariant); it has not been checked on air.
  • TETRA DMO voice chain (#1003, 20 Aug run) now adopts the pipeline's colour over the colour-0 fallback, and both DMO receivers share tetrarx.DMOOptions. The chain's give-up path fell back to baseMNI before adopting the pipeline's 39, and a hint that failed local re-verification once was never retried; adoptLiveColourUnverified now applies the pipeline's (confidence-gated) colour wherever the guess would be used (give-up cap, post-give-up hint, flush) — pinned failing-first by TestDMOVoiceDecoderPrefersPipelineColourOverFallback. The voice chain's EnableDCBlock is now OFF like the pipeline's (the prime suspect for the divergent bursts); still on-air-gated. tetra_mcc/tetra_mnc were missing from config.example.yaml (the 4 Sep rule) — now in a tetra-dmo example.

Download now (ZIP)

  • In this repositoryOne other file it gives its agents.

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.