A fully-featured FM radio receiver built on an ESP32 microcontroller and the SI4703 FM tuner IC, with an SSD1306 OLED display, rotary encoder tuning, hardware seek buttons, RDS text decoding, and persistent NVS storage.
- FM Tuning β 87.5 β 108.0 MHz, 100 kHz steps via rotary encoder with speed-adaptive stepping
- Hardware Seek β Up/Down seek buttons with RSSI-based station validation and mid-seek cancel support
- Volume Control β 0β15 hardware volume levels via dedicated buttons; mute at level 0
- RDS Decoding β Receives and displays PS Name (8-char station name) and RadioText (64-char scrolling info)
- Istanbul FM Station Lookup β Built-in fallback table of ~100 Istanbul FM stations shown when RDS is unavailable
- OLED Display β 128Γ64 SSD1306; shows frequency, signal bars, stereo/mono indicator, AFC rail flag, volume, and scrolling top-line text
- Persistent Storage β Last tuned frequency and volume level saved to ESP32 NVS (survives power cycles)
- Stereo Detection β Multi-sample stereo lock detection with RSSI threshold
- AFC Rail Indicator β Alerts when the SI4703's AFC is saturated (off-center frequency)
| Component | Description |
|---|---|
| ESP32 (DevKit) | Main MCU β dual-core Xtensa LX6, 240 MHz |
| SI4703 | FM RDS Radio Tuner IC (I2C, 3.3 V) |
| SSD1306 128Γ64 | OLED display (I2C) |
| Rotary Encoder | Incremental encoder for frequency tuning |
| 4Γ Tactile Buttons | Vol Up, Vol Down, Seek Up, Seek Down |
| Signal | ESP32 GPIO | Notes |
|---|---|---|
| I2C SDA | 21 | Shared between SI4703 and SSD1306 |
| I2C SCL | 22 | Shared between SI4703 and SSD1306 |
| SI4703 RESET | 27 | Active-low reset, driven by firmware |
| Encoder CLK (A) | 18 | Hardware interrupt |
| Encoder DT (B) | 19 | Hardware interrupt |
| Vol Down Button | 32 | INPUT_PULLUP, active-low |
| Vol Up Button | 33 | INPUT_PULLUP, active-low |
| Seek Down Button | 25 | INPUT_PULLUP, active-low |
| Seek Up Button | 26 | INPUT_PULLUP, active-low |
Note: The SI4703 requires a specific boot sequence β SDA must be pulled LOW before releasing RESET. This is handled automatically in
SI4703Radio::begin(). Do not add an external pull-up on SDA before the ESP32 has booted.
ESP32 SI4703
βββββ ββββββ
GPIO21 (SDA) βββΊ SDA
GPIO22 (SCL) βββΊ SCL
GPIO27 βββΊ RESET
3.3V βββΊ VCC
GND βββΊ GND
ESP32 SSD1306
βββββ βββββββ
GPIO21 (SDA) βββΊ SDA
GPIO22 (SCL) βββΊ SCL
3.3V βββΊ VCC
GND βββΊ GND
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
olikraus/U8g2 @ ^2.35.7
igorantolic/Ai Esp32 Rotary Encoder @ ^1.6No extra board manager URLs are required. All dependencies are available through the PlatformIO registry.
- Framework: Arduino (via ESP-IDF under the hood)
- Flash partition: Default β NVS namespace
"radio"is used for persistence - Wire clock is set to 100 kHz for SI4703 stability; do not increase to 400 kHz
- The ISR for the rotary encoder is placed in IRAM (
IRAM_ATTR) to avoid cache miss crashes
The Si4703 (Silicon Laboratories) is a single-chip FM radio receiver with:
- Integrated LNA, mixer, IF filter, and ADC
- I2C interface (address
0x10) - RDS/RBDS decoder (groups 0A for PS Name, 2A/2B for RadioText)
- Seek/Tune engine with STC (Seek/Tune Complete) interrupt flag
- RSSI register (0β75 dBΒ΅V range) for signal strength
- Stereo blend and AFC (Automatic Frequency Control)
- Register map: 16 Γ 16-bit registers; reads start from
0x0A, writes start from0x02
| Register | Address | Purpose |
|---|---|---|
| POWERCFG | 0x02 | Enable, seek direction, mute |
| CHANNEL | 0x03 | Channel set + TUNE bit |
| SYSCONFIG1 | 0x04 | RDS enable, GPIO |
| SYSCONFIG2 | 0x05 | Volume, band, spacing, SEEKTH |
| SYSCONFIG3 | 0x06 | SKSNR, SKCNT thresholds |
| TEST1 | 0x07 | Crystal oscillator enable (0x8100) |
| STATUSRSSI | 0x0A | RDSR, STC, SFBL, AFCRL, ST, RSSI |
| READCHAN | 0x0B | Current channel + BLERs |
| RDSAβRDSD | 0x0Cβ0x0F | RDS block data |
Configured in SYSCONFIG2 and SYSCONFIG3:
- SEEKTH = 8 (register field, ~26 dBΒ΅V equivalent) β minimum RSSI to stop seek
- SKSNR = 2 β SNR threshold
- SKCNT = 4 β impulse noise threshold
- Post-seek RSSI re-validation: stations with RSSI < 10 are skipped recursively
Core driver class for the SI4703 chip.
| Method | Description |
|---|---|
begin() |
Boot sequence: reset pulse β crystal enable β power up β config |
tuneToKHz(freq) |
Tune to a specific frequency with STC polling |
seek(up, cancelled) |
Hardware seek with cancel-on-button support |
setVolume(vol) |
Set hardware volume (0β15) |
setMute(mute) |
Toggle DMUTE bit in POWERCFG |
pollRDS() |
Parse one RDS frame; updates PS Name and RadioText buffers |
getBestRdsText() |
Returns RadioText if available, falls back to PS Name |
getFrequencyKHz() |
Reads READCHAN register and converts to kHz |
getRSSI() |
Returns raw RSSI byte from STATUSRSSI |
isStereoLocked() |
Multi-sample stereo detection with RSSI guard |
isAfcRail() |
Checks AFCRL bit |
Software debounce class (25 ms debounce window).
| Method | Description |
|---|---|
begin(pin) |
Configures pin as INPUT_PULLUP |
update() |
Must be called every loop; updates stable state |
fell() |
Returns true once on falling edge (press event) |
isPressed() |
Returns true while button is held |
| Function | Description |
|---|---|
handleRotaryTuning() |
Reads encoder delta, applies speed-adaptive step size |
doSeek(up) |
Wraps radio.seek() with mute/unmute and UI status updates |
tuneAbsolute(freq) |
Tune to exact frequency with mute, status, and NVS scheduling |
updateRadioInfo() |
Polls RSSI, stereo, AFC, RDS every 120 ms |
drawScreen() |
Full U8G2 page render: top line, frequency, bottom bar |
servicePersist() |
Deferred NVS write β saves freq/vol after debounce delay |
lookupStationName(freq) |
Binary-friendly table lookup for Istanbul FM stations |
Turning the encoder faster increases the tuning step size:
| Turn interval | Step size |
|---|---|
| β€ 18 ms | 1000 kHz (1 MHz) |
| β€ 35 ms | 500 kHz |
| β€ 70 ms | 200 kHz |
| > 70 ms | 100 kHz (1 step) |
Uses the ESP32 Preferences library under the namespace "radio".
| Key | Type | Default | Description |
|---|---|---|---|
freq_khz |
uint32 | 87500 | Last tuned frequency in kHz |
volume |
uint8 | 12 | Last set volume level |
Writes are deferred (1200 ms for frequency, 800 ms for volume) to avoid excessive flash wear during rapid tuning.
ββββββββββββββββββββββββββββββ
β Metro FM β β RDS / station name (scrolls if long)
ββββββββββββββββββββββββββββββ
β β
β 98.8 MHz β β Large frequency display
β β
ββββββββββββββββββββββββββββββ
β SIG βββββ ST SEEK V:12 β β Signal bars, stereo, status, volume
ββββββββββββββββββββββββββββββ
- Top line priority: RadioText β PS Name β Station table β raw frequency
- Signal bars: 5 levels (0, 8, 18, 28, 40, 50 RSSI thresholds)
- ST/MO: Stereo lock indicator
- AFC: Shown when AFCRL bit is set (tuner is off-center)
- Status area: Shows transient messages (seek, tune, vol) or clears when idle
- Wire up the hardware per the pin table above
- Clone this repo and open in PlatformIO (VS Code extension or CLI)
- Install dependencies:
pio lib install - Build and upload:
pio run -t upload - Open Serial Monitor at 115200 baud for debug output
- The radio boots to the last saved frequency (default: 87.5 MHz on first run)
MIT License β free to use, modify, and distribute with attribution.
- U8g2 β universal graphics library for embedded displays
- Ai Esp32 Rotary Encoder β interrupt-driven encoder library
- Silicon Laboratories SI4703 datasheet and AN230 application note