Part 11 of From Spec to Shipping, a 14-part series on how a protocol
decoder actually gets written — from standards documents and independent
references to code you can trust on air.
Part 10
made the verification ladder explicit — and every rung above
synthetic-green stands on a recording of a real transmitter. This part is
about treating those recordings as what they are: test fixtures with
provenance. The samples/ conventions, the metadata sidecars, the
env-gated harnesses that skip in CI and become instruments in the field,
and the two habits that decide whether a capture answers anything:
asking for the right one, and baselining before you fix.
TL;DR: A capture in GopherTrunk is a binary plus a committed
*.metadata.jsonsidecar carrying provenance and expected bounds — absent bounds are reported, present bounds are asserted, so a fresh capture measures the decoder before anyone commits to a threshold (samples/p25/README.md;TestReplayP25RealCaptureMetricsincmd/gophertrunk/p25_realcapture_metrics_test.go). Bigger captures drive env-gated harnesses —GT_TETRA_DMO_IQ/RATE/MCC/MNC,GT_DIVERSITY_CAPTURE,GT_TETRA_LMS— that skip cleanly with a one-line instruction and turn into field instruments when the file exists. The committed P25 control-channel fixture graded a live NAC 0x2C1 site at EVM 12.7% / SNR 14.5 dB / TSBK 36 decoded, 0 CRC-failed; those numbers are now floors. The rule underneath: record the baseline before touching the code.
Key takeaways
- A capture without metadata is a smoke test; with metadata it’s a
regression gate. The sidecar carries what the decoder should find —
NAC, colour code, yield floors — and
gophertrunk testgrades against it with a CI-ready exit code. - Report first, assert later. Every bound in the schema is optional: a new capture logs its metrics without failing anything, the measured numbers become the committed floors, and the floors tighten as the decoder improves — never the other way.
- The harness must cost nothing when the capture is absent. Skip-gated tests keep CI green with no fixture installed, while the skip message itself documents how to run them — the same file is a unit test in CI and an instrument on an operator’s bench.
- Most capture failures happen before recording starts. Wrong tap (post-combine when the question is the combiner), wrong content (a silent PTT when the question is voice), wrong rate honesty — the asking-for-a-capture checklist is engineering, not admin.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Drop-zone conventions | per-protocol folders, format + acceptance criteria per README | samples/README.md, samples/<proto>/README.md |
| Sidecar schema | provenance + expected bounds; hex-tolerant field matching |
samples/README.md (unified metadata schema) |
| P25 field-truth harness | replays samples/p25/*.cfile, reports/asserts EVM, SNR, sync margin, NID/TSBK yield |
cmd/gophertrunk/p25_realcapture_metrics_test.go (TestReplayP25RealCaptureMetrics) |
| DMO capture harness | replay + colour recovery + verdict lines | cmd/gophertrunk/tetra_dmo_replay_test.go (GT_TETRA_DMO_*) |
| Pre-combine diversity tap | one cs16 file per branch + <prefix>.diversity.json, alignment-guaranteed |
sdr.soapy_remote[].diversity_capture → TestDiversityCombinerReplay (GT_DIVERSITY_CAPTURE) |
| Capture / grade tooling | gophertrunk capture writes IQ + sidecar; gen synthesizes; test grades, exit 0/1 |
samples/README.md (Signal Lab section) |
In this post
- A fixture with provenance — the
samples/layout and what gets committed. - The sidecar schema — bounds that report until you’re ready to assert.
- Env-gated harnesses — one file, two lives: CI test and field instrument.
- Asking for the right capture — the checklist written in scar tissue.
- Baseline before fix — the habit that makes an A/B possible at all.
A fixture with provenance
samples/ is GopherTrunk’s capture drop-zone: one folder per protocol
(p25/, tetra/, nxdn/, dmr-tier2/, mpt1327/, …), each with a
README stating the capture format the loader expects, the metadata needed
to validate a decode, and numerical acceptance criteria — the
explicit thresholds a contributor with hardware can run a capture against
to close a follow-up. The binaries themselves are deliberately
gitignored (multi-megabyte IQ does not belong in source control; anything
under ~1 MB may be committed directly); what is committed is the
sidecar, and the sidecar is where the value lives.
The committed samples/p25/p25-450875-cc.metadata.json shows the shape:
a live UHF C4FM control channel at 449.875 MHz, NAC 0x2C1, channelised
to 48 kHz — and, in its notes, the measured grades: EVM 12.7%, SNR
14.5 dB, sync-margin min=3/median=5, NID trusted=31/failed=0, TSBK
decoded=36/CRC-failed=0. The .cfile never entered git; the fixture is
reproducible from the raw 2 MSPS recording via TestGenerateP25Fixture,
and the sidecar’s bounds are set below the measured values — floors,
not targets, tightened as the demod improves. A capture handled this way
is not a debugging aid that evaporates when the issue closes; it is a
regression fixture with a birth certificate.
The sidecar schema: report until you can assert
The harness side of that contract is TestReplayP25RealCaptureMetrics,
and its expected-payload struct encodes the discipline directly:
// cmd/gophertrunk/p25_realcapture_metrics_test.go (shape)
// p25MetricsExpected is the "expected" payload for samples/p25/*.metadata.json.
// Every bound is optional: a zero/absent field is reported but not asserted,
// so a capture can be dropped in to *measure* the demod before anyone
// commits to a pass/fail threshold.
type p25MetricsExpected struct {
DemodMode string `json:"demod_mode"` // "c4fm" (default) or "cqpsk"
NAC string `json:"nac"` // hex, e.g. "0x167"
MinNIDTrusted int `json:"min_nid_trusted"`
MinTSBK int `json:"min_tsbk"`
MaxEVMPct float64 `json:"max_evm_pct"`
MinSNRdB float64 `json:"min_snr_db"`
MinSyncMargin int `json:"min_sync_margin"`
}
Every bound optional is the load-bearing design choice. Day one, an
operator drops a capture with only demod_mode and nac filled in; the
harness logs pre-FEC EVM, estimated SNR, FSW sync margin and NID/TSBK
yields without failing anything. Those logged numbers are the baseline.
Once trusted, they go into the sidecar as floors, and from that commit on
the capture bites: any regression in the demod fails a named test against
a named recording. The same pattern generalizes across the tree — the
unified schema in samples/README.md adds lock, lock_latency_max_sec
and hex-tolerant lock_fields, and gophertrunk test -capture x.cfile
grades any capture against its auto-discovered sidecar with a CI-ready
exit code. gophertrunk capture and gen both write a sidecar
alongside every file they produce, so a capture is never born naked.
Env-gated harnesses: one file, two lives
Committed fixtures cover the small, stable end. The investigations that
drive this series run on captures too big, too private, or too
operator-specific to commit — and for those, GopherTrunk uses
env-gated harnesses: ordinary go test functions that skip with a
one-line instruction when their variable is unset.
| Variable(s) | Harness | The question it answers |
|---|---|---|
GT_TETRA_DMO_IQ, GT_TETRA_DMO_RATE |
TestTETRADMOReplay |
does a real DMO transmission decode to speech? |
GT_TETRA_DMO_MCC/MNC, GT_TETRA_DMO_COLOUR, GT_TETRA_DMO_CLEAR |
same | fold in the operator’s ground truth (Part 10) |
GT_TETRA_DMO_SCAN=1 |
TestTETRADMOColourScan |
the full 64-colour CRC-yield map |
GT_TETRA_LMS=1 |
TestTETRAMultiSlotReplay |
does the trained equalizer beat CMA on this capture? |
GT_DIVERSITY_CAPTURE |
TestDiversityCombinerReplay |
is MRC helping, hurting, or irrelevant on this rig? |
The skip messages are documentation: set GT_TETRA_DMO_IQ (cs16 IQ) +
GT_TETRA_DMO_RATE to run the DMO replay. CI never sees the captures and
stays green; an operator with a .raw file is one env var from the full
instrument — windowed traces, decode arms, verdict lines. One source
file, two lives, and the two can never drift apart because they are the
same code.
Asking for the right capture
The expensive failures in this workflow happen before a single sample is recorded, and GopherTrunk’s DMO and diversity investigations paid for the checklist the hard way:
Right tap. The MRC combiner lives inside the SoapyRemote driver, so
every ordinary recording path — baseband.auto_record, the scope taps —
sits downstream of it: a capture from any of them has one combiner
already baked in and can never A/B another.
sdr.soapy_remote[].diversity_capture exists solely to tap the branches
before the combine, writing one headerless cs16 file per branch plus a
.diversity.json sidecar — and a datagram that didn’t carry every branch
is dropped from both files and counted, never written short, because
one short write silently desynchronises the branches and poisons every
later conclusion.
Right content. A 25-second silent PTT looked like the perfect
clean DMO vector and turned out to be a poor one — largely comfort-noise
and discontinuous-transmission frames, exactly wrong for grading a speech
descramble. A later run had MRC enabled, a single antenna and no cavity
filter, which contaminated both decode paths at once. The standing ask is
now written into the harness notes: known colour code (from the
codeplug), actually talking, single antenna, combiner off. Similarly,
samples/p25/README.md’s priority ask is not another clean control
channel — it is a marginal voice-channel call, because a strong capture
cannot exercise the
missing equalizer
it exists to justify.
Right physics. IQ or nothing, for phase protocols: an FM-demodulated
audio recording of TETRA has already destroyed the phase the decoder
needs, and 128 kbps MP3 collapses a 4-FSK constellation’s levels —
samples/README.md documents both empirically, from files contributors
actually uploaded. And rate honesty: a 48 kHz TETRA capture gives 2.67
samples/symbol and can never lock, however real the signal. The full
operator-side treatment — formats, sidecars, SigMF, rates — is
The Analog Edge Part 10’s
territory; this series’ concern is what the engineering side must
specify when it asks.
The request itself is part of the diagnostic loop from Part 10: each DMO round ended with a sharper ask, and the sharper ask is what made the next round decisive.
Baseline before fix
The last habit is the one that turns a capture into an experiment:
run the metrics harness and record its output before touching the
code. The P25 weak-signal plan in samples/p25/README.md spells the
order out — baseline the capture with TestReplayP25RealCaptureMetrics
(pre-FEC EVM, SNR, FSW margin, LDU yield), then port the equalizer or
add soft decisions, then A/B against the same file. Without the recorded
baseline there is no A/B — only a vague memory of “it seemed worse
before,” which is how wishful DSP ships.
This is also why the harnesses print yields rather than verdicts wherever a number will do: CRC-valid counts on the same capture are comparable across months and branches — the fixture side of the testing discipline this whole series leans on. The ten-megasamples investigation (#764) was settled exactly this way: the same capture, decimated by an independent resampler, replayed through the proven path — deficit unchanged, therefore in the samples, not the DSP. The capture was the experiment’s control group, and it could be, because nothing about it changed between runs.
How that principle shaped the Go code
- Fixtures load through the production pipeline, not a shortcut. The replay harnesses build the same down-converters and receivers the daemon runs, so a capture grades the shipping code path.
- Sidecar bounds are floors with provenance. Committed thresholds sit
below measured values, with the measurement date and numbers in
notes— so a failing gate points at a regression, not an optimistic guess. - Skip messages are the manual. Every gated test’s
t.Skipstring names the variables and the file format it wants; discovering how to run the instrument is running the test suite. - Capture tools emit metadata by default.
gophertrunk captureandgenwrite the sidecar with the file, so provenance exists from the moment the samples do.
Where this goes next
A capture tells you what the decoder does; it doesn’t yet force you to fix it honestly. Part 12 is the rule that governs every fix in this repo: one narrow commit plus a regression test that fails without the fix and passes with it — and if you can’t write the failing test, you haven’t reproduced the bug, whether or not you think you understand it.
FAQ
How do I contribute a capture to GopherTrunk?
Read the protocol folder’s README in samples/, record IQ in the
documented format (cfile/cs16 at an honest sample rate), and write the
*.metadata.json sidecar — protocol, rates, and whatever ground truth
you have (NAC, colour code, talkgroups). Commit the sidecar; link or
attach the binary per the README. A capture with expected values
validates a decoder; one without is only a does-it-crash smoke test.
Why aren’t the capture binaries committed to the repo?
Size and churn — multi-megabyte opaque binaries bloat every clone
forever. The committed sidecar preserves the provenance and the measured
grades; small (≤ ~1 MB) representative slices are committed directly
when a regression test needs to run everywhere, like the
mmr-s9-cc.cfile-style fixtures under cmd/gophertrunk/testdata/.
What belongs in a capture’s metadata sidecar?
Provenance (source, center frequency, sample rate, format), the receiver
configuration it implies (demod_mode, colour code), and the expected
block: identity fields the decode must match and yield/quality floors.
Start with identity only — the harness reports the rest — and promote
measured numbers to floors once you trust them.
What sample rate should I record at? Higher than you think, honestly labelled. The decoder resamples to its per-protocol channel rate, but information missing from the file never comes back — 48 kHz TETRA structurally cannot lock — and a mislabelled rate poisons everything downstream. When in doubt, capture wide and keep the raw file; GopherTrunk’s tooling can decimate later, and #764 was only solvable because the raw recordings still existed.
Do env-gated tests rot when nobody has the capture? Less than you’d expect, because they compile and their non-gated neighbours share their helpers — and the ones that matter get run every time an investigation touches their protocol. The real rot risk is the opposite arrangement: instruments that live outside the test tree stop building, silently, the week nobody needs them.
Series navigation
Part 11 of 14 · ← Part 10: The On-Air Gate — Green Synthetics Prove Nothing · Next → Part 12: Failing First — The Regression Rule