Part 2 of Signal Lab, a 10-part series on GopherTrunk’s offline signal-analysis workbench. Last time we ran a capture; now we learn to read what the run tells us.
TL;DR: The Signal Lab dashboard is a one-screen verdict on a capture: protocol, sample and symbol counts, effective baud versus expected (with a signed % deviation), lock status/frequency/latency, grants, a symbol histogram, IQ imbalance, a decode-error rate per 1000 symbols, and a demod block with EVM% and an SNR estimate. The single most useful number is the baud deviation: if it drifts more than about 2%, your
-sample-rateis wrong, not your signal.
Key takeaways
- Effective baud vs expected is your rate check. A deviation past ~2% almost
always means the capture’s true rate ≠
-sample-rate. - Lock is binary, but latency is diagnostic. When it locked tells you as much as whether it locked.
- The symbol histogram shows modulation health before any fancy plot — four clean bins for a 4-level C4FM system, smeared bins for a sick one.
- EVM, SNR, IQ imbalance, and decode-error rate are the quality quartet; read them together, never alone.
- Press
eto export the whole result as<capture>.siglab.json.
Cheat sheet
| Command / flag / key | What it does |
|---|---|
gophertrunk siglab -in cap.cfile -sample-rate 2400000 |
Run and open the dashboard |
-protocol p25p1 |
Skip the picker |
-format u8 \| f32 |
Match the capture’s sample format |
-freq <Hz> |
Informational nominal center frequency |
-auto-tune |
Tune out carrier offset before demod |
-verbose-errors |
Full error chain + stack on failure |
↑/↓ or k/j, enter |
Move the picker, run the decode |
e |
Export <capture>.siglab.json |
q |
Quit |
In this post
- The dashboard, field by field — what each line means.
- The 2% rule — turning baud deviation into a sample-rate diagnosis.
- Reading the symbol histogram — modulation health at a glance.
- The quality quartet — EVM, SNR, IQ imbalance, decode-error rate.
- Keys and exports — driving the TUI and getting structured output.
The dashboard, field by field
When a run finishes, the terminal app lands on the results screen. Here’s a representative dashboard for a healthy P25 Phase 1 control channel:
Signal Lab — Results
Protocol: P25 Phase 1
Samples: 12,000,000 (5.00s @ 2400000 Hz)
Symbols: 23,988 effective baud 4797.6 (expected 4800, -0.1%)
Lock: LOCKED @ -12500 Hz (0.42s)
Grants: 3
Symbol histogram
0 ████████·················· 18.9%
1 ███████··················· 30.6%
2 ███████··················· 31.1%
3 ████······················ 19.4%
IQ imbalance: gain +0.12 dB phase +0.4° image rej 43.8 dB
Decode-error rate: 1.20 / 1000 symbols
Demod (C4FM): EVM 7.4% SNR≈19.7 dB
Read it top to bottom:
- Protocol — what the engine decoded as (the one you picked or passed).
- Samples — total IQ samples, the capture duration in seconds, and the sample rate it was decoded at. If the rate here isn’t the file’s true rate, everything below is suspect.
- Symbols — recovered symbol count, then the headline pair: effective baud (the symbol rate the demod actually measured) against the protocol’s expected baud, plus a signed % deviation.
- Lock —
LOCKED @ <freq> (<latency>)orNOT LOCKED. The frequency is the residual carrier offset the receiver settled on; the latency is how long into the capture it took to lock. - Grants — trunking channel grants seen (control-channel activity).
- Symbol histogram — the distribution of recovered symbols across the modulation’s levels.
- IQ imbalance — gain (dB), phase (degrees), and image-rejection (dB) of the front-end’s I/Q balance, shown only when the lab observed enough signal.
- Decode-error rate — errored symbols per 1000, a direct FEC-adjacent health number.
- Demod — the modulation the demod ran, its EVM%, and an SNR estimate.
The 2% rule: baud deviation as a rate check
The most valuable habit you can build is reading the baud deviation first. Both of GopherTrunk’s down-converters normalize a capture to the per-protocol channel rate before demod, which makes the decode path essentially rate-invariant to the capture rate — a clean signal decodes the same whether it was recorded at 2.4 MS/s or 10 MS/s. That invariance is what makes the deviation such a sharp tool: if the true symbol rate is fixed by the protocol and the demod’s measured baud drifts, the drift is telling you the ratio between the rate you claimed and the rate the file was actually recorded at.
The rule of thumb, straight from the docs: if the effective baud drifts more
than about 2% from the expected symbol rate, the capture’s true sample rate
probably doesn’t match -sample-rate. Fix the rate and re-run before trusting
any other metric.
A worked example. Ada’s capture reads:
Symbols: 24,010 effective baud 4996.9 (expected 4800, +4.1%)
Lock: NOT LOCKED
That +4.1% is the whole story. 4800 × (2500000 / 2400000) ≈ 5000 — the file was
recorded at 2.5 MS/s, but she left the default -sample-rate 2400000. Re-running
with -sample-rate 2500000 snaps the deviation back under a percent and the
channel locks. No amount of squinting at the constellation would have helped;
the number named the fault directly.
The corollary, which Reese never tires of repeating: once the rate is right and the deviation is small, a capture that still won’t lock or reads a low SNR is telling you about the captured samples, not the DSP — front-end overload, intermod, or gain staging at record time. The lab has done its job by pointing you at the file rather than the code.
Reading the symbol histogram
Below the top block, the histogram shows how recovered symbols distribute across the modulation’s levels. For a 4-level system like C4FM P25 you want four distinct, well-populated bins; a healthy control channel is roughly balanced across them. When bins smear together or one collapses, the demod is struggling to separate symbol levels — the eye is closing (Part 4), the SNR is low, or the rate is still off.
The histogram is a fast, no-plot sanity check: you can read modulation health from four bars before you ever open a constellation.
The quality quartet
Four fields together describe how well the capture demodulated. Read them as a set, because any one alone can mislead.
| Field | What it tells you | Watch for |
|---|---|---|
| EVM % | RMS error-vector magnitude — distance from ideal symbol points | Rises sharply as SNR drops; the demod-quality headline |
| SNR estimate (dB) | In-channel signal-to-noise the receiver saw | Below ~10 dB on C4FM, lock gets fragile |
| IQ imbalance | Front-end gain/phase balance + image rejection | Large gain/phase or low image-rejection dB points at the SDR, not the signal |
| Decode-error rate | Errored symbols per 1000 | Climbs before lock is lost — an early-warning gauge |
Reese’s mental model: EVM and SNR move together and describe the channel; IQ imbalance describes the front-end; decode-error rate describes the consequences. A capture with great SNR but ugly IQ imbalance was recorded on a radio with a gain/phase problem. A capture with fine IQ balance but low SNR and high EVM is just weak. The four fields let you tell those apart without guessing.
Keys, auto-tune, and exports
The TUI is deliberately small. On the protocol picker, ↑/↓ (or k/j) move
the cursor and enter runs the selected protocol. On the dashboard, e exports
and q (or Ctrl-C) quits. A few flags shape the run:
-auto-tuneestimates the carrier offset and tunes the capture to 0 Hz before demod — reach for it when a capture was recorded off-center and the residual-offset field in the lock line is large.-protocoland-formatskip the picker and set the sample encoding.-freqis purely informational — a nominal center frequency for the report; it doesn’t change decoding.-verbose-errorsprints the full error chain and a stack on failure, which is what you want when a run dies instead of just decoding badly.
Pressing e writes the complete structured result next to your capture as
<capture>.siglab.json:
// cmd/gophertrunk/siglab_tui.go
path := strings.TrimSuffix(m.capture, ext(m.capture)) + ".siglab.json"
// ...
siglab.WriteResult(f, m.result, siglab.FormatJSON)
That JSON carries every field on the dashboard and more, which is how you turn a one-off replay into a fixture or a diff. The browser console (Part 3) exposes the same result through JSON/JSONL/YAML/CSV serializers.
Where this goes next
You can now read a capture’s health from one screen and tell a rate problem from
a signal problem. Part 3
moves to the browser console — gophertrunk siglab serve — where the same
metrics arrive over a live event stream alongside constellations, spectrograms,
and side-by-side comparison. For the canonical field list, the
SigLab docs page stays the source of truth.
FAQ
My baud deviation is +4%. What do I change?
Your -sample-rate. A deviation past ~2% means the value you passed doesn’t
match the file’s true recording rate. Multiply the expected baud by
(true rate / claimed rate) to sanity-check, set the right rate, and re-run.
It locked but SNR is only 9 dB — is that the lab’s fault? No. Because the decode path is rate-invariant, a low in-channel SNR on a correctly-rated capture is a property of the recorded samples (weak signal, front-end overload, gain staging), not the DSP. Get a cleaner capture.
What does image rejection tell me? It’s the front-end’s I/Q balance quality in dB — higher is better. A low image-rejection number with visible gain/phase imbalance points at the SDR or recording chain, not the signal.
Where does the exported JSON go?
Next to the capture, as <capture>.siglab.json, written by the engine’s own
serializer when you press e.
Series navigation
Part 2 of 10 · ←Part 1 · Next → Part 3: The Browser Console