An FM radio station on a $15 FPGA board: plays WAV files from a microSD card and transmits them over FM using nothing but a GPIO pin and a piece of wire. The current track shows on the little ST7789 screen; the two onboard buttons switch tracks and hop between FM frequencies.
Demo videos: playing on a real FM radio · switching tracks with S1
- FM carrier: a DDR DDS — the rPLL makes a 126 MHz clock, a 32-bit
phase accumulator advances by
2*incper cycle, and the IOB's ODDR primitive shifts out two phase MSBs per cycle: an effective 252 MS/s 1-bit carrier synthesizer on a plain LVCMOS33 pin. Frequency modulation is literallyinc = preset + 39*sample(±75 kHz at full scale). - Audio: 16-bit PCM mono WAV files stream from a FAT16/FAT32 microSD card (SPI mode) through a 2 KB FIFO, paced by a fractional divider at the header's sample rate (canonical 32 kHz; 8–48 kHz works).
- Playlist: the FPGA scans the card's root directory for
*.WAVentries (up to 16), shows the 8.3 name on the LCD, auto-advances at end of track and wraps.
| Thing | Details |
|---|---|
| Board | Sipeed Tang Nano 9K (Gowin GW1NR-LV9QN88PC6/I5, 27 MHz) |
| Screen | Sipeed 1.14" ST7789 240×135 SPI (plugs into the board connector) |
| Card | microSD in the onboard TF slot, FAT16 or FAT32 (not exFAT) |
| Antenna | ~75 cm wire (≈ λ/4 @ 100 MHz) on header J5 position 9 (FPGA pin 29) through a ~100 Ω series resistor |
| Radio | any FM receiver |
Controls: S1 = next track, S2 = cycle frequency presets (88.0 / 90.0 / 94.0 / 98.0 / 100.0 / 103.5 / 106.0 / 107.9 MHz — boots at 100.0). Full pin map: docs/pinmap.md.
Assembly tip: the back of the 1.14" LCD module is bare metal and sits right over the board — put a small piece of transparent tape on it, or it can short the USB-C pins / header pads if it flexes down.
This is a milliwatt-class toy transmitter for personal, short-range experiments (a few meters). Still:
- check your local regulations on unlicensed FM transmission — rules differ by country (e.g. FCC Part 15 in the US, similar low-power rules elsewhere);
- pick a free frequency, never overlap a real station;
- keep the antenna short and the range minimal — the 1-bit output also emits harmonics (2f, 3f) and a DDS image at 252 MHz − f (144–164 MHz, near the 2 m amateur band), so don't crank anything up;
- the series resistor is not optional kindness, it keeps the edge rates and radiated harmonics down.
Needs oss-cad-suite (yosys + nextpnr-himbaechel + apicula + iverilog) and openFPGALoader ≥ 1.1.x.
source env.sh # adjust paths (or copy to env.local.sh)
make doctor # check tools + board detection
make bit TOP=m7_fm_radio # synthesize + place&route + pack
make load TOP=m7_fm_radio # load to SRAM (volatile)
make flash TOP=m7_fm_radio # or persist to SPI flashThe design was brought up as a ladder of standalone milestones, each a
buildable top (make bit TOP=...): m0_hello (LEDs/UART), m1_carrier
(bare FM carrier), m2_tone_fm (1 kHz test tone), m3_lcd_text (screen),
m4_sd_stream (SD sector reads), m6_playlist (radio without screen),
m7_fm_radio (everything). Results log: docs/bringup_log.md.
scripts/download_tracks.sh 8 # CC-BY music by Kevin MacLeod
scripts/prepare_sd.sh /Volumes/YOUR_CARD # convert + copy + verifyprepare_sd.sh converts anything ffmpeg can read into the canonical
16-bit/mono/32 kHz WAV the FPGA expects, names files TRACK01.WAV…, and
verifies the header. Keep ATTRIBUTION.md with the music — CC-BY requires
attribution.
Everything above the pins is verified in Icarus Verilog, including the SD stack against a behavioral SPI SD-card model with generated FAT16 and FAT32 images, and the audio path bit-exactly (every PCM sample reproduced in the FM increment stream):
python3 sim/tb/make_test_images.py
make sim TB=tb_sd_player # SD + FAT playlist, both filesystems
make sim TB=tb_audio_chain # SD -> WAV -> FIFO -> FM, sample-exact
make sim TB=tb_dds # DDR DDS phase/output-rate checks
make sim TB=tb_fm_mod TB=tb_pacer TB=tb_text_renderer # unitsrtl/tops/ milestone tops m0..m7
rtl/fm/ fm_mod (presets + deviation), dds_core (DDR DDS)
rtl/audio/ wav_parser, audio_fifo, audio_feed, sample_pacer
rtl/sd/ sd_wav_player (FAT16/32 playlist reader)
rtl/vendor/ WangXuan95 SD SPI core (GPL-3.0, documented mods)
rtl/ui/ lcd_controller (ST7789), text_renderer, font_rom, ui_ctrl
rtl/{ctrl,clocks,dbg}/ por, debounce, player_ctrl, rPLL, UART debug
constraints/ pins (audited vs schematic) + per-clock SDC
sim/ testbenches + SD-card/rPLL/ODDR models + image generator
scripts/ music download / SD prep / font ROM generator
docs/ pinmap.md, bringup_log.md
GPL-3.0 (see LICENSE) — required by the vendored SD core.
- SD SPI/FAT core derived from WangXuan95/FPGA-SDcard-Reader-SPI (GPL-3.0); all modifications documented in the file headers
- font from dhepper/font8x8 (public domain)
- ST7789 driver from the author's earlier FPGA_Screen_UART project
- music (not in repo): Kevin MacLeod / incompetech.com, CC-BY
- DDS-on-a-pin prior art: emard/rdsfpga, jared-s/icefm