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
- Distinctive: line 42
Address the latest follow-up, not the original report: Never re-post the initial fix description as a close justification — respond to the …
- Very specific: line 198
NO tx offset (uplink channel numbers already encode the uplink frequency).
- Distinctive: line 696
Test-fixture lessons: an identical burst repeated for seconds carries a fixed symbol-mean bias no real transmission has, which the …
- Distinctive: line 715
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.
- Very specific: line 804
ALWAYS returns the nearest RM(30,14) codeword (errs = Hamming distance, garbage lands at 4-6), so a faded AACH was a coin-flip …
- Very specific: line 934
NOT RF, NOT post-processing.
- Very specific: line 1041
Watch the CPU angle: every /diag/symbols subscriber builds its own Downconverter from the FULL input rate plus a receiver …
≈ “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.
Files
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 #NoverCloses #Nuntil the fix is verified, so a merge doesn't auto-close an unverified issue. - Closing as
not_plannedorduplicateis 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 atmac_rs_valid=0on 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.IQContainerdelegates to it) and a mono voice twin (voice.FlacWriter):capture -format wav|flacand the API staged capture route throughsiglab.IQContainer(both used to silently write a mislabeled/garbage body — the streamingEncodeCapturehas NO container case and falls through to u8, so gen/synth now reject wav/flac at the boundary);baseband.record[].format: flaccovers both taps (wideband + ddc) and the replayFileDrivermounts .flac back as a virtual tuner (content-sniffed via the fLaC marker, never the extension);recordings.format: flacswitches 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}/audiohandler (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_capturenow takesdiversity_capture_format: flactoo (operator space-saving request): the branch FLAC is a bit-exact twin of the cs16 (same clamp/scale, pinned byTestBranchRecorderFLACIsBitExactTwin) 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-capturestays 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 standardOpcode.String()OSP map (it mislabels — MFID 0x90 opcode 0x00 reads GRP_V_CH_GRANT);ambtOpcodeLabelnames 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, OP25tk_p25.py) with no source unit; GT reusedParseGroupVoiceChannelGrant, 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_UPbase_hzexactly. It had no test at all; pinned byopcodes_explicit_test.go(literal bit positions viatsbkBits). - A P25 TDMA grant's channel number counts LOGICAL channels (#1273): the IDEN_UP_TDMA
(0x33) channel-type code carries slots per carrier (SDRTrunk
ChannelTypeTYPE_0..5 = OP25slots_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 inphase1/identifier.go(SlotsPerCarrier,BandPlan.Timeslot→ 1-basedGrant.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 onIsKnownor 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 onlyAccessTDMA;TopoBandPlanSlot.SlotsPerCarrier+ChannelHznow divide likeBandPlan.Frequency, andp25_band_planentries takeslots_per_carrier(2/4 = TDMA, throughphase1.IdentifierUpdateFromSeed, which also setsAccessTDMAso 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.gowas 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 (OP25p25p2_tdma.cc/p25p2_sync.cc/p25p2_isch.cc/p25p2_duid.cc/apps/tdma/lfsr.py, SDRTrunkSuperFrameFragment/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 sync0x575D57F7FFbefore 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 (OP25extract_vcw==dmrvoicerW..rZ). Logical channels are NOT slot parity: timeslots 10 and 11 swap ({0,1,0,1,0,1,0,1,0,1,1,0}, OP25which_slot[]/ SDRTrunk's final-fragment C/D swap); the grant's 1-basedTimeslot(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.PN44Scramblertapped 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, = ezpwdRS<63,35>pinned by its literal output), then CRC-12 (FACCH 144 / SACCH 168 bits) or CRC-16 (LCCH, OP25 acceptscrc16 == 0); a PDU's messages split by OP25'smac_msg_lentable, and MAC_PTT / END_PTT are fixed layouts (encryption sync + source ride MAC_PTT). Five literal packets were checkedcrc_ok=1by 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, flatProcess) survives only for its own tests andgroundtruth_replay_test.go; delete it in a separate PR.p25_phase2_{trellis,rs, interleave,scrambler}_modeare accepted but unread. The sharedsync.Gardnerapplied 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_pctbefore 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'scall_length/freqList.lenwere filled from the span (trunk-recorder's are the AUDIO length), and avoice_hangtime_mslonger than the engine'scall_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 (engineCallTimeoutnow raises it to hangtime + 5 s). Daemon slog lines (composer:,recorder:) go to STDERR, notmessages.log/event_log— on Windows redirect2> 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_debugcaptures (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.Brokersubscriber 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 (SubBufferLatency400 ms at the stream rate; pinned bybroker_latency_test.go) andwbvoiceWARNs its drop count per call (they were silent). An explicit chunk depth iniqtap.Newkeeps 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: theLDUAssemblerre-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-callcomposer: p25p1 decode qualityline carriesshort_ldus/missing_symbols, and avoice IQ has holesWARN 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 asnon-control DUID duid=PDUand 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 (OP25process_PDUis 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 correctp[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
lowPowerDecodeGraceis healthy whatever its power gauge reads. Same family as the MRC dBFS-gate lesson below. gophertrunk replay -tune-hzuses the single-channelccdecoder.Downconverter(internal/scanner/ccdecoder/ddc.go), not the multi-tap widebandDDCBank(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
.cfileto reproduce. Seeinternal/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.
TestDownconverterSNRInvariantAcrossRatein 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) andcmd/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 withWord32as a 32-bitint— on LP64 the defaulttypedef longis 64-bit and every saturating op returns garbage. Lessons that cost time: the class-2 CRC is a fixed parity-check matrix (the reference'sTAB_CRCtables), not aG(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 (ndbSBSlotShiftintraffic.go). TCH/S now decodes soft-decision like the control SCH path (the receiver'sSoftSinkdifferentials →softType5FromDiffs→ soft depuncture/Viterbi inframing.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 hardTCHSpeechFrameswhen 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'sEnableEqualizer, 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.SnapshotCMAis the differential-decoder-safe variant of the package's plainCMA(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 bysnapshot_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_transitionsand 11 hard CC sync losses (auto-captured byon_cc_sync_loss). Each loss is preceded bybsch_failclimbing /sb_burstscollapsing, then repeatedtetra: 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 thedecode_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 healthyconcurrentcapture measures ~18 dB and replays at BSCH 147/0 (100%); acc_sync_losscapture 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-channelnewTETRAPipelinewas the one TETRA CC path not runningSnapshotCMA(the voice composer andwidebandt2/tetra.goalready 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 byinternal/scanner/ccdecoder/pipelines_tetra_equalizer_test.goagainsttestdata/tetra_cc_sync_loss_2s_144k.cs16.EnableDCBlockstays off the CC path (voice-only perreceiver.go). Gotcha: the equalizer's blind CMA is well-defined only against a noise floor, so the syntheticTestDaemonCCDecodesTETRAnow adds 40 dB AWGN — a literally noise-free constant-modulus input is a degenerate case (same reasonreceiver_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 blindSnapshotCMAlives in the receiver (continuous, framing-free stream); the trainedSnapshotLMSlives 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 newSymbolSink → StashSymbols(the symbol analog ofSoftSink → 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: noStashSymbols/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 bytraffic_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 withGT_TETRA_LMS=1inTestTETRAMultiSlotReplay(comparetraffic_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'sSoftBKN1/SoftBKN2from equalized raw symbols, so the existingDMBurstTCHSpeechSoftdecodes 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)&3de-rotation is a no-op so the rotation-0-trained differentials slot straight in. Opt-in viaExtractDMBurstsEqualized(needs theSymbolSink);ExtractDMBursts/ExtractDMBurstsSoftare byte-identical. Pinned bydmo_equalizer_test.go(raw 12% → 0% DNB payload bit-error, no-harm on clean, byte-identical without symbols). A/B on captures withGT_TETRA_DMO_LMS=1inTestTETRADMOReplay. 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 byTestTETRADMOReplay:- 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),
DecodeDMSCHSgives 64/68 SCH/S andDecodeDMSCHH62/68. The DMO DM-SYNC PDU reuses the TMO SYNC-PDU field layout, soParseSyncPDUdecodes 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/+19is measurably worse. - The real cause of "TCH/S doesn't decode":
DMBurstTCHSpeech/DMBurstTCHSpeechSoftskipped descrambling atcolour==0. TETRA scrambling is non-identity at colour 0 —NewScramblerTetra(0)seeds the LFSR to0xC0000000(§8.2.5.2 eq. 8.42), which is exactly whyDecodeBSCH/DecodeDMSCHSalways descramble and the DSB signalling decodes. The voice path inherited TMOtraffic.go'sif colour != 0shortcut (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#1003fix 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.gonow 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 →DMBurstTCHSpeechreturns 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 confirmtch_crcrises off the chance floor.GT_TETRA_DMO_CLEAR=1flips 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 viaGT_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 withGT_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; LMSGT_TETRA_DMO_LMS=1doesn'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)indmo_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.TestTETRADMOReplaynow auto-recovers the colour whenGT_TETRA_DMO_COLOURis 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 resyncsymptom). What landed:ProtocolTETRADMO(tetra-dmo/dmo) registered ininternal/trunking/site.go(enum,String(),ParseProtocol,Validate) +internal/scanner/ccdecoder/ddc.go(ddcTargetForProtocol→ 144 kHz) + thefactoriesmap inpipelines.go.newTETRADMOPipeline(internal/scanner/ccdecoder/pipelines_dmo.go): the TMO receiver knobs (EnableEqualizerblind CMA +SoftSink+ AFC + channel filter) feeding a newtetra.DMStreamExtractor(internal/radio/tetra/dmo_stream.go) — a bounded sliding-window streaming adapter over the statelessExtractDMBurstsSoft(emits each DSB/ DNB once, in lead order, deduped). DecodesDecodeDMSCHS→ stickyKindCCLocked(no cc.lost on inter-PTT silence, so a camped DMO channel isn't re-hunted) + SYNC-PDU FN liveness,RecoverDMColourCodeonce, and an edge-triggeredtetra-dmoKindGranton a DNB traffic train (re-armed after admoGrantRearmdrought).runTETRADMOVoiceChain(internal/voice/composer/tetra_dmo_voice.go): a self-contained same-carrier chain (one same-carrier tap —sameCarrierVoiceTapsindaemon.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-acelpinrecorder.go). Dispatched fromcomposer.handleStartonproto == "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), andintegration_cc_tetra_dmo_test.go(full-daemon lock,make integration).
- 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 = 529of4^11match 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_totalclimbed 1076→4541 in 185 s (18.7/s) whiledsb_schs_crcsat frozen at 46.dmoGrantMinDNB=4therefore tripped in ~0.22 s,maybeGrantnever checkedp.lockeddespite the counter being nameddnbSinceLock, 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 - 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 requiresp.locked, and the drought is evaluated fromProcess. Pinned failing-first byTestTETRADMOPipelineIgnoresIdleChannel/GrantsOnlyAfterLock/RearmsBetweenTransmissions(all three fail against the old code) plusdmo_grid_test.go. NotebuildDMODibitStreamnow 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-colourRecoverDMColourCodeover 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. Andflush()setcolourKnown = trueoutside the confidence gate, so a call that decoded nothing loggedcolour=0 colour_known=true— read as "recovered colour 0" and sends you after the wrong thing;colourRecoveredis now separate. - 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 group0; 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_qualifiedin the decode-status line is the number that means traffic —dnb_totalis a noise meter, and a large gap between them is normal, not a fault. - 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 whyRecoverDMColourCode'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'stetra_scramb_get_initoffsets — GT'sParseSyncPDUbit 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.
- 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-dmotetra_scramb_get_init; cross-checked against ctn008/TetraDMO-Receiver + lkurkela/osmo-tetra-dmo — GT'sParseSyncPDUoffsets andExtendedColourCodeseed match osmo byte-for-byte). So on an MNI≠0 network the TCH/S traffic seed isExtendedColourCode(250,1,colour), andRecoverDMColourCode'suint32(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_mncsystem config, threaded through the DMO pipeline → grantTETRADMOBaseMNI→ voice chain): fold the configured MNI into every colour candidate (baseMNI | c) and the clear-fallback seed. Pinned failing-first byTestRecoverDMColourCodeNonZeroMNI(scramble withExtendedColourCode(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 TestTETRADMOColourScanwithGT_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). - 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_20augrun (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'sdebug.log:- The control pipeline (
newTETRADMOPipeline) decodes fine: locks,RecoverDMColourCode→ colour 39, andtch_crcclimbs 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 atcolour_hint=0(before the pipeline finished recovery), the chain buffered 242 DNBs, its buffer hitdmoVoiceColourMax=120and 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.tryAdoptLiveColournever 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 runsEnableDCBlock: truewhile the CC pipeline runs it off (receiver.go/ the CC-path note above) — tryEnableDCBlock:falseintetra_dmo_voice.goand A/B. - Why it "ends at a timeout": zero real speech frames ⇒
boundaryTracker.onVoiceis never called ⇒ the call is torn down by hangtime asreason=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 newtch_crcin 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 dropsdataBytes==0orStatProviderzero-voice calls, and ACELP is neither, so a 2-frame call becomes a tiny bogus row. Staged, not shipped.
- The control pipeline (
- 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),
- 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, soH'·(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 runstetra.DMSeedTrackerin 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; atetra_colour_codepins 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 onGT_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 byTestTETRADMOPipelineGrantsOnSeedAdoption(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.
voiceFanoutnow keeps a 1 s pre-roll ring for DMO only (dmoVoicePrerollSeconds, set/cleared by the decoder per activation, delivered as the first chunk ofSubscribeVoiceIQWithPreroll→CCVoiceSource.StreamIQ; the auto-record DDC tap keeps plainSubscribeVoiceIQ). 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.ObserveDNBnow 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 assolve_rejectsin 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 byTestDMSeedTrackerRejectsSolveThatDoesNotDecodeagainst 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: 500is 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/abortedINFO 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.
- The grant waited for
- 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 readMMClockMu()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 absence100 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 — itsambe_okcounts are BOGUS (muted gaps decode as valid all-zero Golay words) andlc_superframes=0— sotrunking.DMRVoiceCadenceDetectednow defaults every DMR protocol (Tier I included; siglab andreplay -record-voicehad 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), andTestDMRIPSCReplaystill needsGT_DMR_INTERLEAVED=1for 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 (gatedProcessGatedvariants, 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 — setppmfromcapture'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) — whichcapture,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 integrationhad NO-timeout, so a slow hosted runner fails a green PR. PR #1175'sintegrationjob died withpanic: test timed out after 10m0sinsideTestSweepImplementationLossBudget(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-raceon that runner — the same lessonci.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 theintegrationjob 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,
TestDMRIPSCReplayinterleaved;TestDMRIPSCBurstDumpis the burst-level instrument), and so do the channelizer arm ofTestDMRIPSCWidebandReplayover 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 loggedsync_hits=0for 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 carrydibits, and the engine self-heals:healDeafTier2resets 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), loggingmm_mu/mm_sps/agc_level/coarse_offset_hzat the reset as the instrument for the next log;deaf_healsin the activity line counts them. Pinned byengine_deafheal_test.go.dmrrx.Receiver.Resetused 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'sdibits+ 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.
macFragmentPayloadskipped 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 feedsTopologySnapshot.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,TestTETRANeighbourReportReplayis 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/macFragmentPayloadhanded everything to the BLOCK end — the MAC length indication (total PDU octets; osmo rx_resrcmacpdu_length*8, tetra-kitdecodeLength(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 throughdecodeDownlinkSlot) 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 ONEcall.releaseper teardown (releaseSeen, re-armed by a fresh grant) — the "release spam" report, same family asgrantSeen/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.
learnNeighbourCellsnow drops the entire list when any cell failsplausibleNeighbourCelland 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 byTestLearnNeighbourCellsRejectsImplausibleCells(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)DecodeAACHis 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 aterrs ≤ 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:ParseDNwrkBroadcastrejects ≥8 trailing bits after the neighbour list (kills the whole splice class even if a new hole appears; reject logged withtl_sdu_hex), and neighbours now EXPIRE afterneighbourExpiry(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, andfrag_abandonsin the decode-status line tracks the rate. Pinned failing-first:TestParseDNwrkBroadcastRejectsSplicedFieldCapture(a LITERAL field dump), half-slot / unclassifiable-AACH / on-grid tests infrag_continuity_test.go, expiry inmle_parse_test.go; no-harm: both 4 Sep captures replay 8/8 real cells throughTestTETRANeighbourReportReplay. - 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 osmomacpdu_decode_sysinfoAND tetra-kitpduProcessSysinfo; 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 publishKindAffiliation(protocol tetra), detaches log. MLE D-RESTORE-ACK wraps a CMCE SDU (5-bit type onward, tetra-kit's forwarding) → decoded viaParseCMCERestoreAckso 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_hzso 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-onlysummarizeEventsilently rendered as an empty detail cell — the "call.talker shows nothing" report; same silent-mismatch family as the Historyr.rowslesson). Still passthrough kinds (docs/api-events.md notes the rename);summarizeEventalso descends into a nestedgrant/Grantobject for call.start/end/complete. - Competitive gap series (Oct 2026) — the subsystems it added and the rules they
follow (
docs/competitive-feature-assessment.mdis 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 isMI ‖ LFSR64(MI)(p25crypto.ExpandMI, = OP25expand_mi_to_128), NOT a left-justified MI (the cryptolab had that wrong). Reference-pinned, no GT capture yet —TestP25ADPReplaywith a DES/AES key is the on-air gate.encryption_keys.algorithmnormalises torc4 | 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_encryptedmutes PCM only (WAV + live tap);.raw/.imb/.ambkeep ciphertext. "Decryptable" is decided byRecorderOptions.KeyConfigured(daemon builds it fromencryption_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 exceptexec(argv split on whitespace, JSON on stdin +GT_ALERT_*) andmqtt(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: analerts.Normalizecase, adefaultMessagecase,config.AlertRuleEventKinds, and a CC Activity label if operator-facing. - Hold / avoid (
trunking/holdavoid.go) sit inHandleGrantafter 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 theAssemble*inverses (tsbkBitsin 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 THROUGHcall_recordingsby path (segments append) — the recorder'sCallComplete.AudioPathis the key, so never rename a recording between CallComplete and the store. - Conventional lockouts persist by FREQUENCY (
storage.ConvLockoutStore, tableconv_lockouts, wired throughconventional.Options.LockedOutHz/OnLockoutChangeincmd/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_interleaveis the conventional priority scan:pickNextChannelvisits the nextPriority-bearing channel after N ordinary picks, and a cursor visit to a priority channel resets that clock (pinned bylockout_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), aweb/configbuilder/src/sections/*.tsx+index.tsxentry,config.example.yaml, and adocs/*.mdwithnav_group: Reference.
- P25 OFB-family decryption (
- Every config key must appear in config.example.yaml when it lands —
diversity_capture_formatshipped 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 aConfigfield, 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-RESOURCEaddressed 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 existingcallGroups(callID→GSSI) map withcallSrc(callID→calling/ transmitting party ISSI), learned from D-SETUP's calling party and D-TX-GRANTED's transmitting party, dropped on release;classifyPartiesbackfills a blank grant source from it (guarded against adest==srcself-source), and it flows totrunking.Grant.SourceID(the grant-dedup snapshot already keys onsrc, so a grant that GAINS a source re-publishes). Pinned byTestCallSourceRestoredMidCall. 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 viareadBasicServiceintoCMCEMessage.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 intoclassifyParties; instead the value is logged on D-SETUP (comms_type_rawintetra: d-setup basic service, debug) so an operator's KNOWN individual-vs-group capture can confirm the split empirically. Only then doesCommsTypeearn a place in classification — the namedComms*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
.ambframes as ground truth (err=[0], identical frame count), GopherTrunk'sambe2-dmrdecode has 4–10× less energy above 1 kHz: octave band fractions (0-300/300-1k/1-2k/2-3k/3-4k) are mbelib0.200/0.513/0.181/0.073/0.033vs GT-raw-ambe2-dmr0.405/0.540/0.041/0.008/0.005. Content matches (envelope corr 0.69 — same speech, crushed highs). Isolation: the baseambe2(3600×2400) decoder shares the exact same synthesis + §6.2EnhanceAmplitudesand gives mbelib-like highs (0.121/0.053/0.020), so the deficit is purely inunpackParams2450(internal/voice/ambe2/params2450.go+tables2450.go) — the 3600×2450 spectral-amplitude quantization reconstruction produces systematically low high-lamplitudes. 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'swarm_dmr_audio: true+enhanceLPF only make it worse (they cut highs further); the synthesis is the cause. Reproduce:gophertrunk decode -in <dmr>.raw -out gt.wav -vocoder ambe2-dmrand compare octave-band fractions to the DSD-FME decode of the paired.amb. IMBE (P25) shows the same direction, milder. - DSD-FME
.imb/.ambinterop VERIFIED (frame-exact); the "slow/pitch" playback is an upstream DSD-FME quirk.recordings.mbe_fileswrites DSD-FME's native cookie-headed container and DSD-FME decodes it cleanly (err=[0]). DSD-FME's-w single.wavwriter 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-wWAV to 12 kHz. Documented indocs/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 fromGrant.DMRInterleavedVoice, which Tier III stamped but Tier II never did —dmr_interleaved_voice: truein config was computed (resolveDMRInterleavedVoice,daemon.go) but dropped on the floor becausetier2.Optionshad no such field; (2) grants carriedTimeslot 0with scalarinCall/lastTG/lastSrcstate, so the engine's(freq, timeslot)identity folded both slots. Fix wiresInterleavedVoicethroughtier2.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'sslotRouterroutes audio by the embedded-LC talkgroup, not by this field. The Terminator-with-LC destination is now decoded (RS seedRS129SeedTerminatorLC) 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-existingdmrSameCarrierTaps = 2taps (distinct serials → no composer per-serial collision). Pinned byconventional_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 owndebug.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 ondmr_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 ininternal/radio/dmr/tier2and pinned failing-first byconventional_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'sPolarityFlipimage (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) — withoutendedAtDibitit re-granted every dead call (7 phantoms/120 s); and the IPSCcolor_codefilter is honoured via the superframe's majority EMB colour code (VoiceSuperframe.EMBColorCode). Capture-verified withTestDMRIPSCReplay:GT_DMR_DROP_HEADERS=1scrubs 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_failin 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) andnoteRadio'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 underindividual/<TG>/and corrupting theindividualcolumn that backs the Radio IDs roster (itsLastTalkgroupSQL excludesindividual=1rows). The whole mechanism is now scoped tog.Protocol == "tetra"; DMR's FLCO classification is authoritative. Pinned byengine_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: poorbadge next to a correctdecode: 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 inspectsProtocolP25systems, so a TETRA/DMO/DMR-only config reported"", the webdemodModeToProto("")fell back top25-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, socomputeQualitytook the MER branch, measured ~9 dB, andqualityVerdictbucketed<10 dBas "poor" for ever. (Tell-tale: the Histogram panel with Mode manually set to TETRA showedSNR (MER) —andbalance ±5.7%, which grades clean — two panels running different receivers on the same carrier.) Fix:symbolProtoFor+SpectrumDevice.SymbolProto(symbol_proto) resolve the actual/diag/symbolsselector per protocol, and the webautoProtoForprefers it. This also removes a whole wrong receiver per open panel. Watch the CPU angle: every/diag/symbolssubscriber builds its ownDownconverterfrom the FULL input rate plus a receiver (internal/scanner/symbolscope/scope.go), nothing is pooled, and Dashboard + Scanner + Plots each mountuseSignalQualitywhile 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
StreamIQsession runs; the example config's example P25 system makes the CC decoder stream the control SDR, so a config withtrunking.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 callDaemon.acquireViewerIQ(viewer_iq.go), which drivesStreamIQitself 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 skipsrole: voiceSDRs (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'sStreamingis 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_dropsis 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 companionccdecoder: decode can't keep up with real timeWARN, which confirms CPU rather than network.sdr.input_sample_rateis a systemwide pre-decimation stage at the Device boundary. When set (and an exact integer multiple ofsdr.sample_rate), the hardware runs at the higher NATIVE rate andinternal/sdr/decimate.Deviceinteger-decimates (polyphase anti-alias FIR,dsp.NewResampler(1, M, …)) down tosample_rateBEFORE anything downstream — DDC bank, demods, recording taps,baseband.auto_record/iqtap, spectrum. It is wired viaPool.WrapDevice(sdrInputDecimatorindaemon.go) so it applies inOpenWithAND survivesReacquire; the wrapper programs the inner device atsample_rate*Mand streams the M:1 result, sosample_ratestays the DECODE rate every existingcfg.SDR.SampleRatereader 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-combinediversity_capturetap 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 byinternal/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-inDemodCQPSK/LSM path (cqpsk.go, thefsefield), 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 brushesminAutotuneLDUs=5incomposer/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 intosamples/p25/(+.metadata.json), baseline it withTestReplayP25RealCaptureMetrics(cmd/gophertrunk/p25_realcapture_metrics_test.go, tagintegration; pre-FEC EVM/SNR/ FSW-margin/LDU yield), then either port thecqpsk.goCMA/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. Seesamples/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 OP25rx_smartnet.cc/h+ trunk-recorderSmartnetParser(both proven on air): 8-bit sync0xAC, 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 withparity[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_modeis now accepted-but-ignored,motorola_band_planselects 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-firstTestProcessDecodesRealAirFormat(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, andcapturenever checksActualSampleRate— though sample-count math shows their file really is 3 MS/s). - The YSF FICH that
internal/radio/ysf/fich.go+fich_trellis.gomodelled was FABRICATED — both files are now DELETED and the voice chain AND the control channel decode viaairframe.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 — NOTframing.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 liveProcessnever fed it anyway — a YSF CC locked on the FSW but never granted.ControlChannel.Processnow runs anAirFramer: header/comms FICH (CM≠3 individual, DT≠data FR) grants on the DG-ID (comms = late entry), terminator clears; pinned failing-first byTestControlChannelGrantsFromOnAirFrames. The lock still follows the bare FSW (siglab's FSW-only fixtures rely on it).DecodeAirFICHis 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 usedemod.ModulateC4FM;ModulateP25C4FMgives ~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 runEnableAFC(CoarseAFC on the discriminator, ahead of the timing loop) +EnableChannelFilter(±4 kHz / ±6.25 kHz) vianewDStarVoiceReceiver/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; interleavesym[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 bybitexact_test.goagainst 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 — onlyairframe.gois 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), soDecodeAirVoicecallsdmrvoice.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 runsEnableAFC(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 byrealair_test.goon 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_filteris 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.Processruns the real framer (airFramer, shared withTrafficChannel) 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 runEnableCarrierTracking(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.
TestEngineDualTETRA200kThroughputnow takesGT_WB_BENCH_RATE; at 3 MS/s it measured 0.39× real time on a 2.8 GHz Xeon with 96 % inDDCBank.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), plusResampler.Processwalked 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.45guardFracrule 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-tapmixBuf). (3) Resampler fast path: only the phases that emit are visited, mirrored 2N history (bit-identical,TestResamplerFastPathMatchesReferencekeeps the old walk as the reference). (4)mixIntoin explicit float32 (NOT bit-identical — Go evaluates complex64 products in float64). (5) AirspyiqConverter: 12 symmetric-pair FIR over a mirrored window + unrolled 4-sample mix cycle, 110 → 213 MB/s (reference kept iniqconverter_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:outheld 8 URBs of 16 KiB = ~11 ms at 3 MS/s; both stream channels are now time-sized (400 ms,streamDepthFor, pinned bystream_depth_test.go). HackRF and AirspyHF still have a fixed 8-deepoutAND 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 registerscramble.gocarried 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'sSACCHCRC6(poly 0x43, preset 0) was WRONG — it verified only all-zero blocks — the reference (MMDVMHostcreateCRC6== DSD-FMEcrc6) 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) offTrafficChannel.OnFramein the voice chain, holding voice until the first VCALL names the cipher (only when keys are configured), publishingcall.encryption(protocol nxdn, alg id = cipher type); keys arealgorithm: scramblerwith a DECIMAL key, on a system or on a scan-list channel (ConvChannelConfig.EncryptionKeys→ pseudo systemsconventional:<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; harnessTestNXDNScramblerReplay(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-serialrateSink) 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 ahost_drops=3873overrun right before the bad audio — read IQ loss before believing a config "fix" (thenarrowband_filterlesson 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.resolvecalled 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 becausewebschema_test.goallow-listed all four NXDN band-plan fields as "editable in the TUI".TestEveryLeafEditableclassifies 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_samplesin 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 sharednarrowband.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 forfe.OutRate(), notsdr.sample_rate(runSingleChannelDecodernow also retries tune/open/decode for ALL of them, ADS-B/LoRa included — they used to die on one failedSetCenterFreq). 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/ModulateFFSKDeviationstate 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 assdr.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 — fourautosticks with APRS+ADS-B pinned used to make the ADS-B stick THE control SDR; and such a stick may run its ownsample_rate. ADS-B: DF 0/4/5/16/20/21 wereCRCValid=trueBY CONSTRUCTION (ICAO = CRC XOR parity, no check at all), so an unfiltered dongle produced ~440 "aircraft"/s with unallocated ICAOs;ProcessFramenow 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" top25-phase2(the TDMA CONTROL-channel pipeline) — a Phase II system's CC is Phase 1 FDMA; it isp25now. 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-framessnippets (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;wmbusnow 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 showedcpus=2on 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: adiag.Reporterprompt must never read a stdin theqstop watcher owns (PromptAllowed). - SoapyRemote RPC opcodes are an upstream enum, and a fake server cannot check them.
callSetAntennawas 600, which isHAS_DC_OFFSET_MODE;SET_ANTENNAis 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 9once per channel, and theboolreply carried no exception sorpcVoidlogged "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 intonewFakeSoapyServerso every test gets it) which catches ARGUMENT-SHAPE drift and found two more calls the fake had not been parsing; andTestOpenSetAntennaUsesUpstreamOpcodepins the numeric opcodes against upstream LITERALS, which is the only thing that can catch OPCODE drift.applyAntennasnow validates againstLIST_ANTENNAS(500) and reads back withGET_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_FORMATreplies 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.cppsetupStream,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.withNativeFormatnow ports the rule; UHD (32768) and non-CS16-native devices are unchanged. The fake server used to replyfullScale 1.0, which nothing real sends; it now reports and streams at a configurablenativeFullScale. 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 nearsqrt(π/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| → 1and freezesh = dc1/dc0— confidently calibrating on nothing, in exactly the weak-signal regime the gate protects. Pinned byTestCrossStatsRejectsCommonDCOffset. - Tracking MRC is safe ahead of a differential decoder; CMA was not — and the difference is
structural, not a tuning choice.
diversity.TrackingCalibratorre-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'srx_subdev_spec=B:0 A:0puts 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 hereh_0is pinned to exactly1+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:TestTrackingCalibratorIsDifferentialSafebounds 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 byargmaxon 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
coherencestuck 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, theiqtapbrokers 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_capturetaps the branches straight after de-interleave, writing one headerless cs16 file per branch (each independently playable viareplay -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 withGT_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:mrcTrackAlphaderived the loop coefficient from the NOMINAL 2 ms window whilemrcCalWindowSamplesclamps 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 (thesymbol_protofix 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_0is degenerate:his estimated by least squares againstx_0, soh_0 = 1by construction ande_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 interferercov(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.IRCCalibratoris 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.
TestDiversityCombinerReplaytraces coherence on the wideband stream AND after each branch's own DDC, then scores eight arms (addwb-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 hadupdatesfrozen at 6682 for eleven minutes whileholdsclimbed ~6000 per interval, and the line said INFO the whole time becausecalibratedwas still true. branch_phase_degin 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 whethermrcormrc-staticis 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 errorsqrt((1−ρ²)/(2Nρ²))is bounded, i.e.ρ ≥ 1/sqrt(1+2Nσ²)— falls as 1/√N yet stays ~8×/~5× above the noise-only floorsqrt(π/4N)(false accept ~e^−50 / 3e−9 per window; noise still can NEVER lock, pinned byTestTrackingCalibratorNeverLocksOnIndependentNoise). The health/WARN line logs the effectivelock_gatefor the stream's window. Pinned failing-first byTestTrackingCalibratorLocksOnBandwidthDilutedCoherence+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
omegaEMAfrom 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.trackre-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) drivescheckPayloadResync: 12 s of signal with a live lock and no payload forces a resync, 3 fruitless resyncs escalate toMarkLost→ re-hunt (mirrored inwidebandt2/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, mirroringcolourConfirmThreshold. - "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=5004on 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):trackcounts 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) needsomegaReprimeStreakEstablished=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) andcheckPayloadResyncbounds the worst case. Pinned failing-first byafc_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 forcarrierOffsetWarnPersist(10 s, test-overridable field), with the excursion clock reset on dips and on pipeline teardown. Pinned byTestDecoderCarrierOffsetWarnRequiresPersistence.
- 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
- 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 withcalibrated=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,SetCenterFreqshort-circuits same-frequency retunes, andclampMagnitudefloors 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 α likemrcTrackAlphaand 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.branchAlignernow 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 awb-aligned-staticarm. Diagnose withTestDiversityCombinerReplay: "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 AFTERupgrader.Upgrade, so an unknown device produced a successful 101 followed by a 1011 close. Browser clients reset their reconnect backoff inonopen, so a handshake that always succeeds makesMAX_BACKOFFdead 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 ononopen; and reconcile the selected serial against a refreshed device list instead of setting it once and never revisiting.web/src/api/reconnectingSocket.tsnow owns the logic the four clients had each copied — including theonerror+onclosedouble-bind that scheduled two timers per failure while remembering only one handle.
- The replay harness's "tracking" arms were silently STATIC (
- 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),
updatesclimbing continuously withholds=0, no anchor flips, ONE benign WARN in the whole log, CC locked in 0.3 s and never lost (bsch_fail=0in 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_secondscap 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.ymldoes not mean the web console builds.npm test(vitest) transpiles with esbuild and never typechecks, so the SPA jobs inci.ymlpass on code thatnpm run build(tsc --noEmit && vite build, i.e.make web-build/make dist) rejects. ThreeTS6133unused-parameter errors inweb/src/api/reconnectingSocket.test.tslanded 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, runcd web && npm run typecheck(ormake web-build) before pushing —npm testalone is not sufficient evidence; and check a PR's non-required jobs, not just the required ones.ci.yml'sweb-typecheckjob now runstsc --noEmitacross all five SPAs (web,configbuilder,siglab,rfscope,cryptolab— the last two are built by no other workflow). Notetsconfig.jsonsetsnoUnusedLocals/noUnusedParameters, so an unused parameter needs a leading underscore (_fn), and test files are insideinclude: ["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.Closestops the HTTP server FIRST, andhttp.Server.Shutdownwaits for active non-hijacked requests without cancelling theirr.Context()— while thebus.Close()that would endhandleSSEruns at the very END of Close, ~90 lines later. So any attached SSE / live-audio / siglab subscriber pinned the whole window, thenShutdownreturnedcontext.DeadlineExceeded, whichdaemon.spawncounts 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.Servernow closes astopStreamschannel at the top ofshutdown()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.Stopwas an unboundedGracefulStop(an openStreamAudiowaits forever), and the soapyremote/rtl_tcp read loops checkedctx.Done()only BETWEEN reads and then parked inio.ReadFullbehind 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.goused to giveRun3 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 readSDR.Devices || Baseband.Replay || SDR.RTLTCPand the network drivers are registered INSIDE that block — so a config with onlysdr.soapy_remote(or onlyka9q_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;TestRemoteOnlySDRConfigsRegisterTheirDriverpins it, asserting on REGISTRATION rather than ond.poolbecause 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.rowswhere 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
labelstable, layered over the alias files at startup.PATCH /api/v1/rids/{id}used to 404 for a radio absent fromrid_alias_file— i.e. exactly the radios worth naming, since one showing up live is by definition not in a file yet — andPATCH /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 taggedDiscovered:DeleteDiscoveredwould 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)becausetalkgroup_file/rid_alias_fileare 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/autotuner are TIME-multiplexed, never concurrent (10 Sep "can't monitor two TETRA systems at once").cchunt.Supervisorhunts systems round-robin andparkUntilUnlockedon the first that locks — on a healthy site the second is never hunted, with no log line saying so (onlysystems=2at startup and the web UI's system list).channels:/voice_taps/tuner_strategyon a non-wideband device are dead config (gated onRole == "wideband"in daemon.go and config_validate.go). The daemon now WARNs +addWarnings both conditions at startup (TestTwoSystemsOnOneControlSDRWarns). The concurrent path isrole: widebandwith onechannels: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 kHzwbvoice.VirtualTunerand run the SOLO chain, never the 4-slot per-carrier demux), sowidebandt2.ChannelVoiceSource(channeliq.go) now fans each wideband channel's own tap IQ out (engineChannel.fan, mirroringccdecoder.voiceFanout) and the daemon registers four of them per wideband TETRA channel (buildWidebandSameCarrierVoiceTaps,d.wbVoiceSources, serialwb:<serial>:same-carrier:<hz>:<n>,CarrierKeykeyed by engine AND frequency). Ordering matters: they are listed BEFORE the VirtualTuners incollectVoiceDevicesbecause a VirtualTuner'sCanTuneaccepts 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
TerminatorDetectorreleased 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) hitSame call's repeated header — dedupeand was never granted. Fix (conventional.go): a header for a tracked call more thanheaderRekeyDibits(0.25 s) past that call'sanchorDibit(its own last header / the LC that late-entered it) is a NEW transmission →releaseCall+ re-grant (Rekeyscounter,rekeysin the widebandt2 activity line); a fresh embedded LC aftersuperframeRekeyGapDibits(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:Processassembles 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.touchonly moves forward). Real-air A/B inTestDMRIPSCReplay(now reads wav/flac containers directly):GT_DMR_DROP_TERMINATORS=1ondmr_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=1unchanged 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=0was becauseIngestBurstdropped 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 alwaysff83df1732094ed1e7cd8a91(2402/2416 bursts) — and BPTC-decoding MMDVMHost'sDMR_IDLE_DATAtemplate (DMRDefines.h; its slot type is a placeholder the host stamps) yields the same 12 bytes with zero corrections (TestIdleInfoPatternMatchesMMDVMHostConstant, the independent-reference pin).handleIdlecounts a BPTC-clean Idle carrying exactlyIdleInfoPatternas a beacon, declares the lock (site camped while idle) and logssite alive (idle beacon)rate-limited; a BPTC-clean Idle with any other block is logged parked withinfo_hex(vendor variant instrument), never counted. Side effect: the widebandt2 "strong in-channel signal but no sync" hint no longer fires on beacon trains (activityClasscounts 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=1by default, a handset tilts the same way) → newrecordings.enhance.tilt_hzfirst-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) →ShapeUnvoicedSpectrumGainnormalises per band (recordings.unvoiced_gain, defaultmbe.DefaultUnvoicedGain=5.49, 1 = equal power, <0 = legacy; raw decoders stay legacy for goldens, the recorder andgophertrunk decodeset it,decode -legacy-synthesisopts 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 inunpackParams2450, 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 forreplace("T", " ")/toISOString().slicebefore shipping a panel. History also refetches after eachcall.end(+30 s visible-tab poll). (2) "duration 30 s, recording 4 s" = per-transmission segment rolls: the recorder publishes oneCallCompletePER OVER stamped with the over's ownStartedAt, and the call log keyed on it — only the first over matched the row, later files were orphaned.CallComplete.CallStartedAt/Segment+ thecall_recordingstable fix the keying;/calls/{id}/audioconcatenates the segments (concatRecordingSegments, inter-over gap restored, capped 1.5 s). Pinned byTestCallLogAttachesEverySegmentRecording,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 recordersanitizewas[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, andAppShellmain keepspb-24at every width. (5) Per-RID playback:/calls/history?source_id=+ a ▶ per recent call in the RID modal (RecordingPlayerinline). - "Host overruns at a tiny 200 kS/s" on the dual-TETRA wideband rig (10 Sep) was CPU, and the
CPU was
DecodeAACHre-ENCODING all 16 384 RM(30,14) codewords per call.DecodeRM3014Tetra/DecodeRM3014TetraSoftwere ML searches that calledEncodeRM3014Tetra(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.gokeeps the brute force as the reference). Second cost wasfilter.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 nodecode can't keep upWARN still means profile the pump —host_dropsis 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) loggedsync_hits=0for 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 offlineTestDMRIPSCReplaygrants 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/BsChannelizerBankvsDDCBankwith 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-bine^{-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;ChannelizerBankuses 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.posis 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 (boundaryTrackeralready startslastMatch=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.rawframes is 3.6 dB, and sweepingrecordings.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'simbe_vocoderis the next instrument. Don't flip the default on one conversation; an operator chasing OP25's balance can setunvoiced_gain: 1and lowerenhance.hpf_hz/tilt_hz. GT's per-call JSON shippedsrcList: []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_seconds120→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 explicitapi.MaxHuntCaptureSeconds=60 instead. Capture NAMES use%.4fMHz (captureName) —%.3frenamed a 442.3875 MHz slice "442.387MHz"; every other frequency name in the tree was already%.4f, andtoFixed(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, dumpcur_mpper frame) of the committedinternal/voice/ambe2/testdata/dmr-voice.raw(the reporter's 378-frame TS2 clip) showsunpackParams2450'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.gopins 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
TestP25ADPReplayit yields 15 LDU1 + 15 LDU2 FEC-clean, every ES0xAA/ KID 1, and with key1234567890the IMBE pitch track goes 0.13 → 0.57 (speech). Two facts the capture pinned, both now inphase1/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), soRewindMIrecovers 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;discriminatorAudioSanityin 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 viagophertrunk 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_refreshBEFORE the late-entry compare) says the same —EPTrackerapplied 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 (mbelibambe3600x2450.c). Pinned byTestEPCaptureIssue1187againstvoice/testdata/ep_issue1187_ptt1.json(literal on-air frames; old semantics fail) andGT_DMR_EP_DUMPis 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 withencryption_keysconfigured 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 ascsbkCRCMask0x5A5A ↔ ETSI 0xA5A5; pinned by a LITERAL vector from an independent Python CRC),dmrvoice.PIHeaderDetectorbeside the terminator detector, anddmrvoice/ep.go: RC4 key = key‖MI (4 bytes), drop 256 keystream bytes (the cryptolabdmrcryptohad 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.EPTrackerfollows 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 fromencryption_keys),composer/dmr_ep.gopublishescall.encryptionfor DMR, captures MI+ciphertext tocrypto_capture_path, descrambles before the recorder. Failing-first:TestComposerDMREnhancedPrivacyDecryptsWithConfiguredKey(old chain records ciphertext). HarnessTestDMREnhancedPrivacyReplay(GT_DMR_EP_IQor DSD-FME-style discriminator audioGT_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 thatreplay -record-voiceDROPPED the start of every call. The reporter works in DSD+/DSD-FME and cannot rungo testharnesses, and the harnesses never exercised the daemon's composer path anyway;replaybuilt noKeyResolver, so an encrypted capture could only replay as ciphertext.-keyreusesbuildKeyResolver+EncryptionKeyConfig.Validate(factored out of system validation), ignoring the grant's system name;-format discremodulates 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 andreplayVoiceSource.pushdiscarded 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.Publishenqueues 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 usereplayDefaultVoiceHangtimeMs(3.5 s), never a tight value. Pinned failing-first byTestReplayDecryptsRealAirEnhancedPrivacyFromDiscAudio: 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, throughprepareDiscInput+ 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 repeatingi&3dibit 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, sorunWatchdog(and the API'sActiveCalls()) readLastHeardAtunlocked whileTouchwrote it per voice frame; both now return copies (every caller acts on a call through its device serial), pinned byTestVoicePoolActiveSnapshotIsRaceFree. 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;TestP25ADPReplayor 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.feedSymbolpushed 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 ^= 1on 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.goholdsBurstBits/Precodeto 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.godecodes the reference ENCODER's own 48 kHz audio (testdata/mdc1200_ref_01_80_1234_48k.s16) throughProcessAudio; 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'sdata[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 throughTestMDC1200Replay(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.channelson RTL R1 +scanner.conventionalon RTL R2 (systems=0,voice_devices=2). Each section alone works; together, 146 ms after startfleetsync: SetCenterFreq failedon 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 endedreason=errorat 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 thedevice opened/ tuner-diag lines and whether the two NESDRs share a hub are the next instrument). Do NOT read the banner'srtlsdr[0]twice as a double-open: the pool snapshot droppedInfo.Index(now carried asSDRStatus.Index). Also landed on request: FleetSync messages carryserial/frequency_hz(bus →fleetsync_logcolumns, migrated in place → REST → a Channel column). The reporter's design ask — attach MDC1200/FleetSync toscanner.conventionalchannels 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/afskmirrors the MDC1200 front end (FM → resample → 1200/1800 Hzdemod.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 wordsFE80083053059B6C/FC80083053057E59repeating — 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.DecodeContainerFilestripped 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), andbaseband.parseIQWavHeader/ReadIQWavStreamHeaderrejected anything but 16-bit.IQWavInfo.Encoding(pcm16 / pcm8 / float32, WAVE_FORMAT_EXTENSIBLE unwrapped, chunks beforedataskipped) now drives the replay driver,UnwrapContainer,DecodeContainerFileand siglab'sprepareWAVInput; pinned failing-first bywav_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 shareddsp.EstimateCarrierCandidatesHzscans 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-tunecallers 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.channelsconfig →afsk.Options.BuspublishesKindFleetSyncMessage/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→/fleetsyncpanel + config-builder section +web.tabs.fleetsync. Adding a decoder like this touches ELEVEN places and three tests police them:TestFieldHelpCoverage(fieldmeta),TestConfigSchemaCoveredByWebBuilder(configbuildertypes.ts) and the webregistry.test.ts/App.panels.test.tsxroute lists — plus thedoctorpreflight "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=441700000and nothing in the UI disagreed. The daemon and the SPA at the operator's commit both wirecenter_hzcorrectly, so the centre was lost client-side: the form coerced withNumber()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.tsparses strictly and errors, the route 400s a centre-without-bandwidth that differs from the tuner centre, the start line carriesrequested_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 withvoice.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) withcoarse_offset_hz≈−20100on 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:healDeafTier2confirms 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, survivesReset). Pinned bycoarse_reject_test.go+ theTestDeafHeal*family. The heal WARN is now once per 10 min per channel (DEBUG after;deaf_healscounts). Also fixed in that path: the beacon delta was computed AFTERec.lastCnt = c(always 0, latent), andCounters.Dibitswas never incremented (every activity line readdibits=0on 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;TestDMRIPSCReplayplus 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,ChannelCutoffHz6250 = 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.newCarrierGateCalibratedsets 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 —op25setsunvoiced_gain1 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_hzonPOST /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 carrycapture_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
EnableChannelFilteron the CC taps the neighbour is gone; these three are independent, each pinned failing-first:- 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 mosttier2DeafRefDecayDb=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 byTestDeafHealIgnoresTrainEdgeWindow/TestDeafHealTracksGenuineLevelChange(engine_deafheal_test.go). - 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
CallEndis deferred waiting for the chain's drain signal when the nextCallStartlands.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:finalizeDrainingBeforeReusefinalizes the deferred previous call with its ownCallEndbefore the reuse, and every finalize/drain path is now fenced byGrant.CallID(callIDsDiffer,NotifyDrainCompleteForCallthreaded composer→fanout→recorder) so a stale drain can neither finalize nor pre-drain the next over. Pinned byTestRecorderRekeyDuringPendingDrainKeepsNextOver(old recorder records one over and emits one CallComplete; fixed records both). - 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=1puts 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 withintimingAcqAgreeSamples=1 (mirroring the coarse acquirer's two-windows-agree rule); aftertimingAcqMaxWindows=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 testdmr-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.
- Deaf-heal false-fires every idle gap (7 heals in 3 min).
- 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 thedc_avoiddefault 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 are0xE00002xx; the USB family's are0xE0004000 | x(IOKit/usb/USB.h):kIOUSBPipeStalled0x4f,kIOUSBTransactionTimeout0x51. A rawIOKit kern_return 0xe00040xxin 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 throughDeviceRequestTO(v182 device interface, vtable 30) with the caller's 300 ms timeout. The baseDeviceRequesthas none and hung for the kernel's ~5 s default, exactlysdr 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
Resetonly 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-writeUSB_SYSCTLtimeouts). Transports now declareusb.LocalResetter;probeAttemptsForgives 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.NewGoertzelROUNDS 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 usesNewGoertzelExact+ 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 fromctcssMinDeviationHz. (3) DCS was the self-consistent trap again: codeword layout AND bit order invented, andsynthesizeDCSIQsent 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 thenrz_inverted=log was waiting for: D025N → false (1 bits = positive deviation), D025I → true. The gate therefore accepts onlytone.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.conventionalchannels (#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 factoryconv_decoders.go, same afsk receivers + bus kinds as the dedicated-SDR sections, stamped scanner serial + channel frequency).dataFrontEndinteger-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→ protocolam-conv→ the FM chain witham=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,amAudioScale0.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 onamCNMeter(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 needsAudioResampler{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 Hzfm_audio_highpass_hzapplies 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 inscanner/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 owntest.wav(4-channel 12 kHz AM audio, 7 messages): GT decodes all 7 to acarsdec's fields, and acarsdec decodesreceiver.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 totest.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 throughGT_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
ActivityDebounceto 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 thandmrBurstBridge(100 ms) after its last above-squelch chunk; analog keeps the #1090 debounce. Same run:conv: StreamIQ failed … rtlsdr: stream already activewas 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 onimbe/testdata/p25p1-tone-bracketed-voice.raw), while the analog chain has NO AGC in production (AudioAGCis 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 ridesGrant.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 reacheswriteRawFrame. - The conventional scanner's FM squelch measured the WHOLE SDR span, not the channel
(#1239).
squelch_dbfswasPowerDbFSof 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 onchannelPowerMeter(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 (thedmrrx.carrierGatediscriminator-variance idea), not built. Per-channelgainlanded 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=linuxTermux tarballs (-tags nolibasoundto drop purego's libdl, which a Linux build links even with cgo off) and they died beforemainwithSIGSYS: bad system callon an S5 (LineageOS) and an Android 14 phone: Android puts every app under a seccomp filter that answersfaccessat2with SIGSYS (bionicSECCOMP_BLOCKLIST_APP.TXT), and Go'ssyscall.Faccessattriesfaccessat2first on GOOS=linux and skips it ONLY on GOOS=android.os/exec.LookPathreaches it whenever the program EXISTS, andatotto/clipboard's init (via bubbles/textinput) looks uptermux-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 faketermux-clipboard-setis on PATH). Other LookPath callers (colorprofile'stmux info,-web'sxdg-open, alertsexec) would hit it too, so patching callers is whack-a-mole and a-overlayof the stdlib is refused when GOROOT is in GOMODCACHE. Fix:scripts/build-android.shbuilds 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.shfails the build unless the interpreter is Android's, NEEDED is that allowlist, andgo tool nmshows nosyscall.faccessat2(failing-first: it rejects the v1.2.3-style binary on both counts). CI'stermux-staticjob and both release workflows use the runner's preinstalledANDROID_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 byTestPowerSweepOverRTLTCP(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 30sweep took ~1 min while the borrowed hunt IQ reader floodedhunt: decode falling behind. Hops now carry a wall-clock budget (powersweep.Integration.Budget= interval/hops − settle) and the CSVsamplescolumn is the honest count;powernotes it once (quietDropssilences 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 acomplex64product 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).
CTCSSDetectornow decimates to ~48 kHz, channel- filters ±8 kHz and normalises to the calibration rate;ctcss_realair_test.gointerpolates 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 tobaseMNIbefore adopting the pipeline's 39, and a hint that failed local re-verification once was never retried;adoptLiveColourUnverifiednow applies the pipeline's (confidence-gated) colour wherever the guess would be used (give-up cap, post-give-up hint, flush) — pinned failing-first byTestDMOVoiceDecoderPrefersPipelineColourOverFallback. The voice chain'sEnableDCBlockis now OFF like the pipeline's (the prime suspect for the divergent bursts); still on-air-gated.tetra_mcc/tetra_mncwere missing from config.example.yaml (the 4 Sep rule) — now in atetra-dmoexample.
In this repositoryOne other file it gives its agents.
- docs/llms.txtllms.txt
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.

