To build a dedicated, headless Raspberry Pi web radio, use a Raspberry Pi Zero 2 W paired with an I2S DAC like the Adafruit MAX98357A and a KY-040 rotary encoder. This setup pulls internet audio streams directly via the mpv media player, bypassing the noisy onboard PWM audio, while physical controls let you change stations and adjust volume without needing a screen or smartphone app.
Difficulty: Intermediate (Requires basic soldering, Linux CLI comfort, and Python 3).
Time to Build: 2-3 hours (excluding 3D printing or enclosure woodworking).
Estimated Cost: ~$32 USD (excluding speaker and enclosure).
Hardware BOM and Pin Mapping
The Pi Zero 2 W is the ideal target board for this build. It draws roughly 1.2W at idle, has built-in 2.4GHz Wi-Fi for streaming, and costs around $15. We are using the Adafruit MAX98357A I2S amplifier breakout because it handles the digital-to-analog conversion and 3.2W amplification on a single chip, avoiding the terrible signal-to-noise ratio of the Pi's native analog video jack.
| Component | Exact Variant / Model | Approx. Cost |
|---|---|---|
| Microcontroller | Raspberry Pi Zero 2 W (with headers) | $15.00 |
| Audio DAC/Amp | Adafruit I2S 3W Class D Amp (MAX98357A) | $7.50 |
| Station Selector | KY-040 Rotary Encoder Module | $2.00 |
| Speaker | 3W 4-Ohm Full Range Driver | $5.00 |
| Misc | 16GB MicroSD, 5V 2.5A PSU, Jumper Wires | $12.00 |
GPIO Pin Mapping Table
Wire the I2S data lines to the Pi's dedicated PCM pins. The rotary encoder can use any standard GPIOs, but we are using pins with built-in pull-up resistors enabled in software to save external components.
| Module Pin | Pi Zero 2 W GPIO / Physical Pin | Function |
|---|---|---|
| MAX98357A VIN | 5V (Pin 2 or 4) | Power for Amp & Pi |
| MAX98357A GND | GND (Pin 6) | Common Ground |
| MAX98357A DIN | GPIO 21 (Pin 40) | PCM_DOUT (Data) |
| MAX98357A BCLK | GPIO 18 (Pin 12) | PCM_CLK (Clock) |
| MAX98357A LRC | GPIO 19 (Pin 35) | PCM_FS (Word Select) |
| KY-040 CLK | GPIO 16 (Pin 36) | Encoder A |
| KY-040 DT | GPIO 20 (Pin 38) | Encoder B |
| KY-040 SW | GPIO 26 (Pin 37) | Push Button |
| KY-040 VCC | 3.3V (Pin 1) | Logic Power |
| KY-040 GND | GND (Pin 9) | Common Ground |
OS Configuration and Audio Routing
Raspberry Pi OS Bookworm (the current standard) uses PipeWire and Wayland by default, which can intercept ALSA audio streams. For a headless embedded radio, we want direct ALSA access to the I2S DAC to minimize latency and CPU overhead.
- Flash the OS: Use Raspberry Pi Imager to flash Raspberry Pi OS Lite (64-bit) to your SD card. Enable SSH and configure Wi-Fi in the advanced settings.
- Enable the I2S Overlay: SSH into the Pi and edit the boot config:
Add the following line at the bottom to enable the generic I2S DAC overlay and disable the onboard noisy audio:sudo nano /boot/firmware/config.txtdtparam=audio=off dtoverlay=i2s-dac - Install Dependencies: We use
mpvfor robust network stream buffering andgpiozerofor hardware control.sudo apt update sudo apt install mpv alsa-utils python3-gpiozero python3-pip pip3 install python-mpv --break-system-packages - Verify Audio Output: Run
aplay -l. You should seesndrpihifiberryorsndi2sdacas card 0. Test it withspeaker-test -t sine -f 440 -c 2 -l 1.
aplay -l still shows vc4hdmi as card 0, PipeWire is overriding your ALSA config. Disable the PipeWire user service for a headless build: systemctl --user disable --now pipewire.socket pipewire.service.
The Python Control Script
This script targets the Pi Zero 2 W running Python 3.11+. It maps a list of stream URLs to the rotary encoder. Turning the knob changes the station; pressing the knob mutes/unmutes. Volume is controlled via ALSA amixer commands to avoid interrupting the stream buffer.
#!/usr/bin/env python3
import time
import subprocess
import logging
import sys
from gpiozero import RotaryEncoder, Button
from signal import pause
# --- Configuration & Pin Definitions ---
# KY-040 Pins
ENCODER_A = 16 # CLK
ENCODER_B = 20 # DT
BUTTON_PIN = 26 # SW
# Stream URLs (Replace with your preferred stations)
STATIONS = [
'http://stream.radioparadise.com/aac-320',
'http://ice1.somafm.com/groovesalad-128-mp3',
'http://ice1.somafm.com/defcon-128-mp3'
]
# Setup Logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Hardware Initialization
try:
# max_steps=0 allows infinite rotation wrapping
encoder = RotaryEncoder(ENCODER_A, ENCODER_B, max_steps=0, wrap=True)
button = Button(BUTTON_PIN, pull_up=True, bounce_time=0.05)
except Exception as e:
logging.critical(f'Failed to initialize GPIO: {e}')
sys.exit(1)
current_station_index = 0
is_muted = False
mpv_process = None
def play_station(index):
global mpv_process
url = STATIONS[index]
logging.info(f'Tuning to station {index}: {url}')
# Kill existing stream
if mpv_process and mpv_process.poll() is None:
mpv_process.terminate()
mpv_process.wait()
try:
# --no-video, --no-terminal, --cache=yes for network resilience
cmd = [
'mpv', '--no-video', '--no-terminal', '--cache=yes',
'--cache-secs=10', '--ao=alsa', url
]
mpv_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE
)
except FileNotFoundError:
logging.error('mpv binary not found. Run: sudo apt install mpv')
except Exception as e:
logging.error(f'Failed to start mpv: {e}')
def change_station():
global current_station_index
# Read encoder steps, map to station list
steps = encoder.steps
new_index = steps % len(STATIONS)
if new_index != current_station_index:
current_station_index = new_index
play_station(current_station_index)
def toggle_mute():
global is_muted
is_muted = not is_muted
state = 'mute' if is_muted else 'unmute'
logging.info(f'Audio {state}')
# Use amixer to toggle hardware mute on the I2S DAC
subprocess.run(['amixer', '-q', 'set', 'Master', state])
# Bind Events
encoder.when_rotated = change_station
button.when_pressed = toggle_mute
if __name__ == '__main__':
logging.info('Raspberry Pi Web Radio started. Press Ctrl+C to exit.')
play_station(current_station_index)
try:
pause() # Keep script running efficiently
except KeyboardInterrupt:
logging.info('Shutting down...')
if mpv_process:
mpv_process.terminate()
encoder.close()
button.close()
sys.exit(0)
Debugging: Exact Errors and First Checks
When embedding audio on Linux, ALSA routing is where 90% of builds fail. If your radio is silent or the script crashes, follow this decision path.
The First Three Things to Check
- ALSA Device Recognition: Run
aplay -l. If your I2S DAC isn't listed ascard 0,mpvis sending audio to the HDMI/PipeWire void. Re-check thedtoverlayinconfig.txtand reboot. - Network Route to Stream: Many public radio streams use HTTP, not HTTPS. If your network blocks port 80, or the station URL has changed,
mpvwill exit immediately. Test the URL manually:mpv --no-video [URL]. - 5V Rail Sag: The Pi Zero 2 W and the Class D amp draw transient current spikes when audio peaks. If using a cheap phone charger, the 5V rail may dip below 4.6V, causing the Pi to brownout and the encoder to misread steps. Use an official Raspberry Pi 5V 2.5A supply.
Exact Error String: ALSA lib pcm_dmix.c:1032:(snd_pcm_dmix_open) unable to open slave
If your Python script throws this error in the mpv stderr log, it means the ALSA dmix plugin cannot access the hardware device because something else has locked it exclusively.
- Cause 1 (Most Likely): PipeWire or PulseAudio is running in the background and holding the I2S DAC. Fix: Run
systemctl --user stop pipewire.serviceandsudo systemctl disable pulseaudio. - Cause 2: A zombie
mpvprocess from a previous crash is still holding the audio node. Fix: Runkillall mpv. - Cause 3: The
dtoverlay=i2s-dacis conflicting with another SPI/I2C overlay inconfig.txt. Fix: Comment out unused overlays likedtparam=spi=onif not needed.
Extending or Simplifying the Build
To Simplify: If you don't want to solder an I2S DAC, you can use a $3 USB Audio Adapter (like the Sabrent USB-SA). Change the mpv command flag to --ao=alsa:device=hw=1,0 (assuming the USB dongle is card 1). You lose audio fidelity, but gain plug-and-play simplicity without editing config.txt.
To Extend: Add a 128x64 I2C OLED display (SSD1306) to show the current station name. Wire SDA to GPIO 2 and SCL to GPIO 3. Use the luma.oled Python library to render text. You can also integrate Raspberry Pi's official audio HATs if you need line-out instead of a built-in speaker.
Frequently Asked Questions
Can I use a Raspberry Pi 4 instead of the Zero 2 W for this web radio?
Yes, the code and pinouts are 100% compatible with the Raspberry Pi 4 Model B and Pi 5. However, the Pi 4 draws roughly 3W-5W at idle compared to the Zero 2 W's 1.2W. For a dedicated appliance that runs 24/7, the Zero 2 W is vastly more power-efficient and generates less heat inside a small wooden or 3D-printed enclosure. Only upgrade to the Pi 4 if you plan to add heavy local DSP (Digital Signal Processing) or run a local media server alongside the radio.
Why does my raspberry pi web radio stutter on high-bitrate streams?
Stuttering on 320kbps streams is almost always a Wi-Fi buffering issue, not a CPU bottleneck. The Pi Zero 2 W's 2.4GHz antenna is tiny and susceptible to interference from microwaves and Bluetooth devices. First, increase the mpv cache in the Python script by changing --cache-secs=10 to --cache-secs=30 and adding --network-timeout=60. If it persists, move the Pi closer to the router or use a USB-OTG cable to attach a Wi-Fi dongle with an external antenna.
How do I add a physical display to my raspberry pi web radio?
The most reliable display for headless embedded audio is a 128x64 I2C OLED (SSD1306 chip). It draws less than 15mA and requires only 4 wires (VCC, GND, SDA, SCL). Install the luma.oled library via pip. You can hook into the Python script's change_station() function to clear the screen and draw the new station name using Pillow (PIL) fonts. Avoid HDMI or DSI touchscreens for this build; they require GPU memory allocation and Wayland compositors, which bloat the OS and ruin the instant-boot nature of a dedicated radio.






