Part 8 of The Operator’s Cockpit, the series on driving one GopherTrunk daemon through one REST + SSE API from both a browser and a terminal. Part 7 put the wideband spectrum and waterfall on a canvas. This post zooms all the way in — past the FFT, past the channel filter — to the recovered symbols a P25 receiver is deciding on, and shows how three different DSP scopes (constellation, eye, symbol) all read the *same diagnostic stream off the daemon.*
TL;DR: The daemon exposes one WebSocket,
GET /api/v1/diag/symbols, that spins up a parallel P25 receiver on a chosen device + offset and streams aSymbolFrameper batch — pre-slicer soft waveform, complex sym_i/sym_q decision points, oversampled eye_soft samples, and sliced dibits, plus receiver-loop metrics. Three React panels subscribe to that one stream and render three views: a constellation scatter, a folded eye, and a rolling symbol oscilloscope. Anoffsetquery param mixes an off-centre channel to baseband server-side so its symbols clear the SDR’s DC spike. The client reconnects with jittered backoff; the whole thing is a diagnostic tap, not a decode path.
Key takeaways
- One stream, three scopes. Constellation, Eye, and Symbol Scope are three
renderers over a single
SymbolFrameWebSocket — the same one-contract-many- renderers pattern the whole series turns on. - The frame carries every representation at once. Soft waveform, complex decision points, oversampled eye samples, and sliced dibits ride together, so each panel picks the fields it needs and the demod runs once.
- Offset tuning happens on the server. The panel sends
offsetin Hz; the daemon mixes that off-centre channel down to baseband before channelizing, so a locked voice/control channel appears clear of the centre DC spike. - C4FM and CQPSK are genuinely different pictures. CQPSK has a real complex constellation; C4FM is constant-envelope FM with a 4-level eye, not a constellation — the UI models that difference instead of faking it.
Cheat sheet
| Piece | What it does | Where it lives |
|---|---|---|
| Symbol WS endpoint | streams SymbolFrame per batch |
internal/api/symbols.go (handleSymbolStream) |
SymbolProvider |
daemon seam to the parallel receiver | internal/api/symbols.go |
| Stream client | connect / reconnect / decode frames | web/src/api/symbols.ts (openSymbolStream) |
| Constellation | complex scatter (CQPSK) / IQ ring (C4FM) | web/src/panels/Constellation.tsx |
| Eye diagram | fold eye_soft over the symbol period |
web/src/panels/EyeDiagram.tsx |
| Symbol scope | rolling soft-waveform / dibit oscilloscope | web/src/panels/SymbolScope.tsx |
| Dibit wire fix | force a JSON number array, not base64 | internal/api/symbols.go (DibitArray) |
In this post
- The one symbol stream — the WebSocket, its frame shape, and the DC-spike trick.
- The daemon seam — how
SymbolProviderkeeps the API free of DSP. - Three scopes over one frame — what each panel pulls from the same batch.
- C4FM vs CQPSK — why the UI draws two different pictures on purpose.
- The wire gotcha — why dibits need a custom JSON marshaller.
The one symbol stream
Every DSP scope in this post is fed by exactly one endpoint. The daemon runs a parallel P25 receiver — separate from the live decoder — on whatever device and channel offset the operator points it at, and streams the recovered symbols out as JSON text frames over a WebSocket:
// internal/api/symbols.go (shape)
// SymbolProvider is the daemon-side abstraction the symbol endpoint
// consumes; the API package never imports the DSP package.
type SymbolProvider interface {
// proto selects the receiver ("p25-c4fm" / "p25-cqpsk"); offsetHz
// tunes an off-centre channel down to baseband before channelizing.
OpenSymbolStream(ctx context.Context, serial, proto string, offsetHz int32) (<-chan SymbolFrame, func(), error)
}
// handleSymbolStream answers WS /api/v1/diag/symbols?device=…&proto=…&offset=…
func (s *Server) handleSymbolStream(w http.ResponseWriter, r *http.Request) {
// …validate device + proto (p25-c4fm | p25-cqpsk), parse offset
frames, cleanup, err := s.symbols.OpenSymbolStream(ctx, serial, proto, offset)
defer cleanup() // MUST run on disconnect — it tears the parallel receiver down
// …ping every 30s; marshal each SymbolFrame to a text message
}
The important structural fact is that handleSymbolStream knows nothing about
DSP. It validates the query, opens a stream through the SymbolProvider
interface, and pumps frames — pinging every 30 seconds so a proxy can’t idle the
socket, and calling cleanup on disconnect so the parallel receiver doesn’t leak.
The DSP lives behind the seam, in internal/scanner/symbolscope; the API package
stays a transport.
The single most useful field in the request is offset. An SDR’s own DDC leaks a
residual carrier at 0 Hz — the DC spike — that lands on top of anything tuned
to the centre of the band. So the panels don’t ask you to centre-tune the channel
you care about; they send its offset in Hz and the daemon mixes it down to
baseband before the receiver ever sees it:
// internal/api/symbols.go (shape)
offset := int32(parseIntQuery(q, "offset", 0, -30_000_000, 30_000_000))
// …clamped to the device Nyquist client-side so the mix can't just alias back
That is why all three panels share the same little offset-and-Hold control: with Hold off, the offset follows the newest active call on the selected SDR (or rests on the control channel); pinning Hold freezes it. The symbols of a locked channel appear clear of the centre spike because the server moved them there.
How that principle shaped the frame shape
A SymbolFrame is deliberately over-complete: it carries every representation of
the batch at once, so a single demod pass feeds three very different pictures.
// internal/api/symbols.go (shape)
type SymbolFrame struct {
SymbolRateHz float64 `json:"symbol_rate_hz"`
Soft []float32 `json:"soft"` // pre-slicer soft waveform (empty on CQPSK)
SymI []float32 `json:"sym_i"` // complex decision points — the true
SymQ []float32 `json:"sym_q"` // constellation (empty on C4FM)
EyeSoft []float32 `json:"eye_soft"` // oversampled matched-filter output (C4FM eye)
EyeSPS int `json:"eye_sps"` // samples per symbol; fold over this
Dibits DibitArray `json:"dibits"` // sliced decisions (0..3 C4FM, 0..1 bits)
IsBits bool `json:"is_bits"`
// …CarrierOffsetHz, AGCLevel, ClockMu, CMAError — receiver-loop metrics
}
Soft is the analog waveform before the slicer makes a decision; SymI/SymQ
are the complex decision points sampled once per symbol (the true constellation);
EyeSoft is oversampled matched-filter output at EyeSPS samples per symbol for
folding into an eye; Dibits are the final hard decisions. When a field applies
it’s aligned index-for-index with the dibits; when it doesn’t it’s empty. One
frame, four ways of looking at the same symbols.
The stream client: connect, decode, reconnect
On the browser side, openSymbolStream is the shared connection scaffold all
three panels use — the same reconnect discipline the IQ and audio streams use
elsewhere in the app:
// web/src/api/symbols.ts (shape)
export function openSymbolStream(cfg, opts): SymbolStream {
const connect = () => {
const ws = new WebSocket(symbolWebSocketURL(cfg, opts)); // …?device=&proto=&offset=
ws.onmessage = (ev) => {
const frame = JSON.parse(ev.data) as SymbolFrame;
if (frame && Array.isArray(frame.dibits)) opts.onFrame(frame);
};
const onDown = () => {
setStatus("closed");
const wait = jittered(backoff); // 500ms → 30s, halved + jittered
backoff = Math.min(backoff * 2, MAX_BACKOFF);
reconnectTimer = window.setTimeout(connect, wait);
};
ws.onerror = onDown; ws.onclose = onDown;
};
connect();
return { close() { /* …stop reconnects, close ws */ } };
}
A panel picks its receiver with a small Mode control that resolves through
demodModeToProto: Auto follows the modulation the selected SDR is actually
decoding (device.p25_modulation), falling back to C4FM when unknown; an explicit
choice is used verbatim. Because the receiver, the ideal-cluster markers, and the
tuning label all key off that one resolved proto, “Auto” just works — the scope
matches whatever the daemon is decoding without the operator thinking about it.
Three scopes over one frame
Each panel keeps a rolling buffer and repaints a <canvas> on every frame — the
identical technique Part 7 used for the waterfall. What differs is which fields
they read and how they draw them.
Constellation is a 2D scatter. On CQPSK it pushes the complex decision points straight in; on C4FM — which has no complex domain — it falls back to either the raw IQ ring or the four soft levels on the real axis:
// web/src/panels/Constellation.tsx (shape) — onFrame
const pts: IQPoint[] = [];
if (f.sym_i && f.sym_i.length > 0) {
const n = Math.min(f.sym_i.length, f.sym_q?.length ?? 0);
for (let k = 0; k < n; k++) pts.push({ i: f.sym_i[k], q: f.sym_q[k] });
} else if (f.soft && f.soft.length > 0) {
for (const s of f.soft) pts.push({ i: s, q: 0 }); // C4FM soft levels on the real axis
}
pushPoints(pts); // additive-blended, age-faded, DC-blocked, auto-scaled
The render is deliberately OP25-flavoured: hollow amber rings mark the ideal cluster centres (±45° diagonals for CQPSK, ±1/±3 on the real axis for C4FM soft levels), points are drawn additively so dense clusters bloom toward cyan-white while the noise floor stays dim, and the newest samples are brightest. A clean CQPSK signal reads as four tight clusters; a closing eye smears them into an X.
Eye diagram reads only eye_soft + eye_sps and folds the oversampled
matched-filter output over the symbol period, overlaying the windows:
// web/src/panels/EyeDiagram.tsx (shape) — onFrame, C4FM only
if (!f.eye_soft || f.eye_soft.length === 0) return;
const buf = eyeRef.current.concat(f.eye_soft);
eyeRef.current = buf.slice(Math.max(0, buf.length - WINDOW_SAMPLES));
setEye({ samples: eyeRef.current, sps: f.eye_sps || 0 });
A healthy C4FM channel shows four open horizontal bands with clear gaps at the
decision instant; a closed centre means symbol-timing or SNR trouble. The eye is a
property of the FM-discriminated baseband, so this panel is C4FM-only and pins its
proto to p25-c4fm — CQPSK’s quality view is the constellation instead.
Symbol scope is a rolling oscilloscope of the soft waveform with the sliced dibits overlaid, keeping the two tracks aligned only when a frame carried a full soft track:
// web/src/panels/SymbolScope.tsx (shape) — onFrame
const sb = softRef.current.concat(f.soft ?? []);
const db = dibitRef.current.concat(f.dibits ?? []);
const aligned = sb.length === db.length; // soft present ⇒ keep both; else dibit rows only
softRef.current = aligned ? sb.slice(-WINDOW_SYMBOLS) : [];
dibitRef.current = db.slice(-WINDOW_SYMBOLS);
Three panels, three canvases, one WebSocket — and because the demod runs once on the daemon, opening all three at different offsets costs three parallel receivers, not three copies of the UI’s imagination. The operator docs walk each one as a finished surface: the Constellation, Eye diagram, and Symbol scope pages.
The wire gotcha: dibits aren’t base64
There’s one Go-specific trap worth calling out, because it silently broke the
scopes once. Go’s encoding/json renders a []byte (which is []uint8) as a
base64 string, not a number array — and the web client expects number[],
so it would quietly drop every dibit. The fix is a named type with a custom
marshaller:
// internal/api/symbols.go (shape)
// DibitArray forces the number-array form on the wire; a nil/empty slice
// becomes [] so the field is always an array, never null.
type DibitArray []uint8
func (d DibitArray) MarshalJSON() ([]byte, error) {
if len(d) == 0 { return []byte("[]"), nil }
// …append '[', strconv.AppendUint each value comma-separated, append ']'
}
It’s a two-line lesson that generalizes: any time a Go []byte needs to reach a
JavaScript typed array as numbers, you marshal it yourself. The client even
guards on it — openSymbolStream only forwards a frame when frame.dibits is a
real Array, so a malformed payload is dropped rather than crashing a scope.
Where this goes next
Part 9 climbs back out of the DSP and onto a map: plotting P25 sites and position-bearing emitters (APRS, AIS, ADS-B, DSC) on a shared Leaflet canvas, wired to the daemon’s locations and sites endpoints, with live marker patching as fixes arrive. After the abstract complex plane, it’s the most literally situational panel in the cockpit.
FAQ
Do the scopes decode anything, or just visualize?
They visualize. GET /api/v1/diag/symbols runs a parallel diagnostic receiver;
it never touches the live decode path. Opening a scope can’t disturb what the
daemon is recording — it just taps the same SDR through the broker pool.
Why is the eye C4FM-only but the constellation works for both? Because they measure different things. The eye is the FM-discriminated baseband folded over the symbol period, which only exists for constant-envelope C4FM. CQPSK/LSM has a genuine complex constellation instead, so its quality view is the scatter — the panels model that difference rather than forcing one view on both.
What does the offset control actually do? It sends a frequency offset in Hz that the daemon mixes down to baseband server-side before channelizing, so a channel that isn’t at the SDR’s centre appears clear of the 0 Hz DC spike. With Hold off it follows the newest active call; Hold pins it to a chosen (or control-channel) reference.
Why three panels instead of one with tabs?
Because they carry different persisted preferences and follow-logic, but they all
subscribe to the identical SymbolFrame stream — the “one contract, many
renderers” spine of this whole series. Splitting the render, not the data source,
keeps each panel simple.
How does the client survive the daemon restarting?
openSymbolStream reconnects with a jittered exponential backoff (500 ms up to
30 s), the same discipline the audio and IQ streams use. A restart shows as a
brief “connecting” pill, then the scope resumes when the socket comes back.
Series navigation
Part 8 of 14 · ← Part 7: Live Spectrum & Waterfall in the Browser · Next → Part 9: The Map — Plotting Sites & Emitters