SDR in Pure Go, Part 4: DSP Foundations — Filters, NCO & AGC

Part 4 of SDR Internals. Before any protocol can be decoded, the raw IQ has to be shifted, filtered, leveled, and resampled. These are the DSP primitives in internal/dsp — and the Go design that keeps them fast.

In this post

  • The four workhorses: filters, the NCO, AGC, and the resampler.
  • Why every primitive is a stateful streaming object, not a pure function.
  • The zero-allocation Process(dst, src) convention that keeps the hot path off the garbage collector.

What the DSP foundation does

Wideband IQ off a dongle is messy: it’s centered at the wrong frequency, it spans far more bandwidth than the signal you want, and its amplitude swings with the band. Four primitives fix that:

  • NCO (numerically-controlled oscillator) — a software local oscillator that multiplies the IQ by a rotating phasor to shift a signal to baseband. (reference)
  • Filters — FIR, CIC, and half-band filters that remove everything outside the channel and let you decimate the sample rate down. (FIR, CIC)
  • AGC (automatic gain control) — normalizes amplitude so downstream slicers see a consistent signal level. (reference)
  • Resampler — a polyphase rational L/M resampler that converts between the dongle’s rate and a protocol’s symbol clock. (reference)

The learn-path lesson Filtering & decimation covers the theory; this post is about the code.

How GopherTrunk implements it in Go

Each primitive is a small struct that carries state across calls. A streaming FIR filter must remember the tail of the previous chunk to filter the next one seamlessly, so it owns a history ring buffer:

// internal/dsp — shape of a streaming primitive
type FIR struct {
    taps    []float32
    history []float32 // carries the boundary between chunks
    pos     int
}

// Process filters src into dst, reusing dst's backing array.
func (f *FIR) Process(dst, src []float32) []float32 { /* ... */ }

The NCO is the same idea with a single piece of state — its phase accumulator — advanced sample by sample so the phasor stays continuous across chunk boundaries. The AGC tracks a running magnitude estimate (an exponential moving average) and scales toward a target level. The resampler keeps its polyphase filter state between calls.

Factory functions handle the math-heavy design step once, up front: LowpassKaiser(n, fc, beta), RootRaisedCosine(sps, span, alpha), and Gaussian(sps, span, bt) compute filter taps so the hot path only ever does multiply-accumulate.

The design principle: stateful streaming + zero-allocation reuse

Two principles work together here. First, these are stateful objects, not pure functions, because continuous streaming requires memory of the past. Second, the Process(dst, src) convention reuses caller-supplied buffers so the streaming path allocates nothing.

How that principle shaped the Go code

  • Process(dst, src) []T is the universal signature. The caller passes a destination slice; the method fills and returns it, reallocating only if capacity is too small. In a tight loop the same buffers are reused millions of times and the garbage collector never sees the hot path.
  • History lives inside the object. Because each filter owns its ring buffer, chunk boundaries are invisible — you can feed 6 ms at a time and get identical output to feeding the whole signal at once.
  • State means not concurrency-safe — by design. One stateful primitive is driven by exactly one goroutine in the pipeline (Part 3), so no locks are needed inside the DSP at all.
  • Design once, run hot. Expensive Kaiser/RRC tap computation happens in a constructor; the per-sample loop is pure float multiply-add, which Go compiles to tight, predictable code.

Where this goes next

Filters and oscillators are the alphabet; the rest of the DSP series spells words with them. Channelizers (Part 5) are filters arranged in polyphase banks; demodulators (Part 6) chain a matched filter onto a discriminator. A future “DSP in Go” series can go deep on Kaiser window design and fixed-vs-floating-point trade-offs.

FAQ

Why floating-point DSP instead of fixed-point? float32 keeps the code simple and is plenty fast on modern CPUs, while sidestepping the scaling and overflow bugs that plague fixed-point. The zero-allocation buffer reuse matters far more for performance than the numeric type.

What does AGC actually protect against? Slicers and timing loops assume a roughly constant signal level. As a signal fades or a transmitter keys up, AGC keeps the amplitude near a target so those later stages keep working without re-tuning thresholds.

Why is a CIC filter used for decimation? A CIC filter decimates with only adds and subtracts — no multiplies — which makes it the cheapest way to drop a high sample rate before a sharper FIR cleans up the passband.

Series navigation

Part 4 of 14 · ← Part 3 · Next → Part 5: Tuning & channelization