Back to all work
Personal build · In progress · July 2026 – Present

Real-Time FPGA Spectrum Analyzer

Verilog · Gowin GW1NR-9 (Tang Nano 9K) · Digital DSP from first principles

A live spectrum analyzer implemented in Verilog on a Sipeed Tang Nano 9K. An external SPI ADC samples an analog signal, the FPGA computes its frequency content, and the spectrum renders to a display — all as parallel digital logic, with no processor in the signal path. The endgame is to feed the radar gun's amplifier output into this board: analog RF front end → ADC → FPGA DSP → display, the literal architecture of a production radar processor at hobby scale.

Six modules simulation-verified Fixed-point DSP in progress Hardware bring-up on Tang Nano next
Bench-verified analog front end (MCP3008 SPI ADC + MAX4466 mic), waiting for Tang Nano bring-up.
What it crosses
Verilog RTLFSM DesignClock Domain CrossingFixed-Point DSPVideo TimingTestbench Verification

The function of a spectrum analyzer is solved — Audacity does it. The implementation is the point: clock domain crossing, hard real-time pixel timing, fixed-point pipelined math, wire-level protocol implementation from first principles. Every module has a self-checking Icarus Verilog testbench and, where applicable, a Python / NumPy fixed-point golden model the RTL is verified against. Nothing touches the board until its testbench passes.

Architecture

The signal chain

Two clock domains — sampling (~20 kHz) and pixel (25 MHz) — bridged by a gray-code async FIFO. That FIFO is the single most important structural decision in the project.

Input
MAX4466 mic (or radar amp)
Anti-aliased with a passive RC before the ADC
ADC · 20 kHz sample domain
MCP3008 SPI ADC
Custom SPI master FSM drives SCLK; MOSI on falling edges, MISO sampled on rising
Clock domain crossing
Gray-code async FIFO (BRAM)
Only one bit of the pointer changes at a time — mid-transition reads are safe. Then 2-flop synchronized.
DSP · pixel domain
Fixed-point Goertzel filterbank (in progress)
16 frequency bins, MAC arithmetic time-shared across bins
Timing
VGA/DVI timing gen
640×480 @ 60 Hz from first principles
Output
Spectrum renderer → HDMI
DVI-over-HDMI via TMDS (hardware only)
Verification

Module status

Every module verified in simulation against a self-checking testbench — and where DSP is involved, against a NumPy fixed-point golden model on identical input vectors, so the RTL output matches the reference to within quantization error.

ModulePurposeSimulationHardware
uart_tx.v8N1 UART transmitter, FSM✓ VerifiedPending
uart_rx.vUART receiver, mid-bit sampling✓ VerifiedPending
spi_mcp3008.vSPI master, two-edge discipline✓ VerifiedPending
synchronizer.v2-flop metastability synchronizer✓ VerifiedPending
async_fifo.vGray-code dual-clock FIFO (BRAM)✓ VerifiedPending
vga_timing.v640×480 @ 60 Hz timing generator✓ VerifiedPending
test_pattern.v8-bar color test pattern✓ VerifiedPending
goertzel_bin.vFixed-point Goertzel per-binIn progress
fft_pipelined.v256-pt radix-2 pipelined FFTPlanned
tmds_serializer.vHDMI/DVI output stageHardware only
Clock domain crossing

The FIFO is the whole project

The ADC samples in one clock domain (~20 kHz), the display runs in another (25 MHz pixel clock). Wiring data across raw risks metastability: a flip-flop sampling a signal mid-change gets stuck between 0 and 1, corrupting data randomly. Two mechanisms make this safe:

2-flop synchronizer
  • Two flip-flops in series in the destination domain. If the first goes metastable, it settles before the second samples it.
  • Clean output, one cycle late. For single-bit signals only.
Gray-code async FIFO
  • The real bridge for multi-bit data. Shared memory, separate read/write pointers.
  • Pointers cross in gray code: only one bit changes at a time, so a mid-transition read is at worst off by that one bit.
  • Then synchronized through the 2-flop synchronizer. 40 values crossed between two different clocks in order, uncorrupted.
Debug story · async_fifo.v The FIFO passed first try, but three testbenches failed before the checker was right — all from the FIFO's one-cycle read latency (assert rd_en, data appears the next cycle). Nailed it only after a cycle-by-cycle probe printing rd_en and rd_data side by side to see the gap. Lesson: when a synchronous memory seems "off by one," it's usually read-latency in how you're checking it, not a bug in the thing.
Bring-up stories

What each module taught me

uart_rx.v · testbench bug, not DUT bug Receiver decoded 0xA5 correctly but the testbench hung forever. Traced with $monitor on internal state — rx_done pulsed for exactly one clock cycle at t=895 ns, but the TB's send_byte task was still holding the stop bit, so wait(rx_done) wasn't reached until the pulse had already gone low. Fix: a parallel always @(posedge clk) block that latches the one-cycle pulse the instant it fires. Lesson: distinguishing a DUT bug from a testbench bug.
spi_mcp3008.v · off-by-one-bit signature First result was 0x14A — exactly 0x2A5 >> 1, i.e. shifted one bit. Recognized the "off by one bit" signature immediately → traced to a one-edge misalignment between when the chip presents each bit and when the master samples. Fixed the sampling phase.
vga_timing.v · bad boundary math Color bars first came out doubled/shifted. Used x[9:7] (which chops the 1024-value range) instead of dividing the 640 visible pixels into 80 px bars. Symptom pointed straight at bad boundary math. Verified: exactly 307,200 visible pixels (640×480), 420,000 total (800×525).
Analog front end

Bench-verified before the FPGA

Confirmed the MCP3008 + MAX4466 chain without the FPGA in the loop: 3.3 V rail steady, VDD/VREF referenced, mic output biased mid-rail (~1.65 V), signal present at ADC CH0 (meter wobble on sound). SPI pins waiting for FPGA bring-up. This is the same debugging discipline used on the radar gun: isolate every stage, measure at every node, and don't power the next thing until the previous one measures right.

Roadmap

What's next

DSP · Stage 1
  • 16-band Goertzel filterbank in fixed-point Q1.15, MAC arithmetic time-shared across bins.
  • An honest, complete spectrum analyzer on its own — renders as 16 bars.
  • Verified against a NumPy fixed-point reference on identical input vectors.
DSP · Stage 2
  • Pipelined 256-point radix-2 FFT: butterfly unit, twiddle ROM, bit-reversed addressing, alpha-max + beta-min magnitude approximation.
  • Upgrade rendering to a scrolling waterfall (spectrogram) stored in BRAM.
  • Simulation output has to match NumPy's FFT within fixed-point quantization error.
Hardware bring-up
  • TMDS serializer + rPLL for the pixel clock on the Tang Nano's HDMI connector.
  • Live oscilloscope trace of the mic signal first — whistle and watch the sine wave.
  • Timing closure report + resource utilization documented in the repo.
Radar integration
  • Feed the Doppler radar's amplifier output into the ADC (already biased near mid-rail — convenient).
  • Calibrated axis: bin → Hz → mph at 31.4 Hz/mph.
  • Final demo: point at a passing car, the spike sits at its speed — all-hardware DSP.
Concepts exercised

Interview currency

Combinational vs. sequential logic · Clock domain crossing · Metastability · Gray-code FIFO · Hard real-time · Pipelining · Fixed-point Q-format · Timing closure
Toolchain

Stack

VerilogGowin EDAIcarus VerilogGTKWavePython / NumPyMCP3008 SPI ADCTang Nano 9KGit