Skip to content

Isolating one FM channel in the FPGA

A worked example of putting DSP into the AD9361 datapath: the board delivers one 200 kHz WBFM (wideband FM broadcast) channel, every other station removed in the fabric before a sample leaves the board. It is the opt-in patch firmware/patches/optional/0003-wbfm-channelizer.patch; read this to build it, or as a template for your own FPGA filter.

Building it

The commands, and the check before flashing: build the FM channelizer example.

default station 102.1 MHz (Studio Brussel); the station is a runtime setting (retuning)
why delete the project first the coefficients and the block-design change both need the FIR IP regenerated, and build_hdl.tcl reuses an existing pluto.xpr: without it the build silently produces the old filter
why it is opt-in it narrows RX channel 0 to one broadcast channel
expected after the build 96 / 220 DSP48s in utilization.rpt, and timing met

Running it

The four iio_attr commands and the A/B check: build the FM channelizer example. The second rate, 528000 on cf-ad9361-lpc, engages the filter: it drives GP_CONTROL bit 0, which selects the filtered path through the bypass mux.

Retuning to another station

The filter sits at DC and follows the LO, so any FM station works at runtime as long as the sample rate stays 4.224 MSPS:

# LO for a given station, in Hz
LO = station - 1056000

The flowgraph's Station (Hz) slider does this. A different sample rate needs a rebuild: pick Fs = 384000 x k, set FS_IN in gen_fir_coe.py, regenerate and rebuild.

Why the channel is shifted to DC first

Tuning on-channel puts the radio's own defects in the audio: a zero-IF receiver like the AD9361 (it mixes the tuned frequency straight to 0 Hz) leaks its local oscillator and carries a DC offset, both at exactly 0 Hz. So tune off-channel. But a FIR with real coefficients is symmetric about DC: asked to pass 1.0–1.2 MHz it passes −1.2 to −1.0 MHz too, a different station. Complex coefficients would cost four real filters instead of two.

Instead, shift the channel to DC, then lowpass. With the offset at exactly Fs/4, multiplying by exp(-j*pi*n/2) cycles through 1, -j, -1, +j, which is only swaps and sign flips: two registers and a 2-bit counter, no multipliers, no NCO (numerically controlled oscillator), no block RAM.

n multiply by (I, Q) becomes
0 1 ( I, Q)
1 −j ( Q, −I)
2 −1 (−I, −Q)
3 +j (−Q, I)

The rate plan

The offset must be Fs/4, and Fs/8 (after the ÷8 decimator) should be a whole multiple of 48 kHz so audio needs no resampler: Fs = 384000 x k. For an offset near 1 MHz, k = 11:

Station 102.1 MHz
AD9361 sample rate 4.224 MSPS (= 384000 × 11)
Offset = Fs/4 1.056 MHz
RX LO 101.044 MHz (= 102.1 − 1.056)
FPGA decimation ÷8
Delivered rate 528 kSPS, channel centred at DC
Audio decimation 528000 / 48000 = 11, exact

2.083 MSPS (the AD9361's floor, Nyquist ±1.042 MHz) and 2.304 MSPS are too slow: the channel's upper edge needs 1.2 MHz.

The filter is what keeps the LO spur out. After the shift the spur sits at −1.056 MHz = 2 × 528 kHz, so decimation folds it exactly onto DC, the centre of the channel. The FIR's stopband there (about −84 dB; −78.5 dB worst case) is what suppresses it. gen_fir_coe.py prints that frequency's response; re-check it if you change the rate plan.

The datapath

The receive path with the patch applied, top to bottom. The AD9361, with its LO at 101.044 MHz and 4.224 MSPS: the wanted channel sits at +1.056 MHz, the LO leak and DC offset at 0. axi_ad9361 passes adc_data_i0 and adc_data_q0. rx_ddc (ad_fs4_ddc.v) is new: it multiplies by exp(-j pi n/2), with 0 DSPs and 0 block RAM, so the wanted channel is now at DC and the spur is pushed to -1.056 MHz. rx_fir_decimator is the existing block with new coefficients: a 321-tap lowpass, Fpass 100 kHz, Fstop 175 kHz, then divide by 8. cpack, adc_dma and USB deliver 528 kSPS: the channel and nothing else. The receive path with the patch applied, top to bottom. The AD9361, with its LO at 101.044 MHz and 4.224 MSPS: the wanted channel sits at +1.056 MHz, the LO leak and DC offset at 0. axi_ad9361 passes adc_data_i0 and adc_data_q0. rx_ddc (ad_fs4_ddc.v) is new: it multiplies by exp(-j pi n/2), with 0 DSPs and 0 block RAM, so the wanted channel is now at DC and the spur is pushed to -1.056 MHz. rx_fir_decimator is the existing block with new coefficients: a 321-tap lowpass, Fpass 100 kHz, Fstop 175 kHz, then divide by 8. cpack, adc_dma and USB deliver 528 kSPS: the channel and nothing else.

TX is unchanged. Channel 1 is not a usable second receiver with this build. The patch pins rx_filt_chan to 2 regardless of patch 0021 (its coefficients are one FM channel, so RX2 through them would give the same station twice), and cpack clocks every channel on channel 0's strobe, so channel 1 gets a 1-in-8 downsample with no anti-aliasing: ±264 kHz usable, with roughly ±2 MHz folded on top. See both-receive-channels.md.

File What it does
firmware/src/hdl/projects/pluto/ad_fs4_ddc.v the Fs/4 shifter
firmware/src/hdl/projects/pluto/system_bd.tcl wires it in, repoints the coefficients
firmware/patches/optional/0003-wbfm-channelizer.patch both of the above; opt-in, setup.sh does not apply it
firmware/scripts/gen_fir_coe.py designs and verifies the coefficients (no MATLAB needed)
firmware/scripts/coefile_wbfm_102100.coe its output, 321 taps
docs/grc/fishball_wbfm_rx.grc the GNU Radio receiver, with no software channel filter

Filter performance and cost

321 taps, Kaiser window, quantised to 16-bit integers summing to 2^17 (the stock DC-gain convention):

Passband ripple, 0–100 kHz 0.0019 dB
Stopband edge, 175 kHz −113 dB
Adjacent channel carrier, 200 kHz −78.7 dB
Worst case anywhere ≥ 175 kHz −78.5 dB
Before After
DSP48s 72 96 / 220 (43.6%)
Slice LUTs 11 893 12 664 / 53 200 (23.8%)
Slice registers 20 851 22 300 / 106 400 (21.0%)
Worst negative slack — +0.292 ns, 0 failing endpoints of 55 269

The shifter costs no DSPs; the +24 is the filter going from 129 to 321 taps. Taps are cheap because a ÷8 filter has eight input periods per output. More than ~321 taps does not help: the filter is then limited by 16-bit coefficient quantisation.

Designing your own filter

# run from: firmware/scripts
python3 gen_fir_coe.py          # stdlib only: no venv needed

Standard library only. It prints the response at the passband edge, stopband edge, adjacent-channel carrier and folded LO spur, checks ripple and worst-case stopband, and exits non-zero rather than writing a file that fails. Edit the configuration block at the top to retarget it. gen_fir_coe.m is the MATLAB equivalent (equiripple, ~30% fewer taps, needs the Signal Processing Toolbox).