Turn a spare Raspberry Pi into a headless internet-radio and USB music player that streams to a Bluetooth speaker at the best codec the speaker supports. Everything is controlled from a web UI — no screen, no keyboard, no app to install.
Built for 512 MB boards: idles around 135 MB RAM and plays at about 176 MB.
A whole player: Pi, USB Ethernet dongle, music stick — and the optional Blinkt! LED strip acting as a live VU meter.
- Web UI only — stations, playback, EQ, Bluetooth pairing, network and shutdown at
http://safydo.local - Best-quality Bluetooth — bundles a source build of bluez-alsa with SBC-XQ, AAC, aptX, aptX-HD and LDAC, and picks the best codec the speaker offers
- Internet radio + local files — a bundled station list, thousands more via Radio Browser and Jamendo, plus USB sticks and SMB/NFS shares
- Survives power loss — logs on tmpfs, tuned ext4, minimal SD writes
| Board | Raspberry Pi Zero 2 W or Pi 3 Model A+ (both 512 MB) |
| SD card | 8 GB or larger |
| Power | A supply that can hold 5 V under load — 2.5 A recommended |
| Speaker | Any Bluetooth A2DP speaker or headphones |
| Network | A USB Ethernet or WiFi dongle — see the warning below |
| Optional | USB stick for local music; a Pimoroni Blinkt! for a physical LED VU meter |
About the network dongle. Both boards share one 2.4 GHz radio between WiFi and Bluetooth, and A2DP audio stutters when they compete. The installer therefore disables the onboard WiFi once a USB uplink is already carrying the board — it will never cut the link you are connected over. A USB Ethernet dongle gives the most reliable audio; a USB WiFi dongle also works. On a Pi 3 A+ note there is only one USB-A port, so a dongle and a music stick need a powered hub.
Other Pi models are not tested. The installer detects the board and refuses to apply tuning it does not recognise, so it should be safe to try.
Use Raspberry Pi Imager with Raspberry Pi OS Lite, 32-bit. Desktop images are not supported.
Either release works on either board. Bookworm and Trixie are both supported, on both the Zero
2 W and the 3 A+ — the installer detects the board and the OS release as two independent things and
tunes each separately. The board decides the hardware tuning (overclock fragment, USB gadget link,
whether the WiFi radio is dual-band); the OS decides the package handling (BlueZ and bluez-alsa
versions, whether /tmp is already tmpfs). Nothing depends on a particular pairing, so pick
whichever release you prefer — Trixie if you want the newer BlueZ, Bookworm if you want the
longer-settled base.
In Imager, open the settings (gear icon) before writing and set:
- a username and password — modern Pi OS creates no default account, and skipping this leaves a board that refuses every SSH password
- Enable SSH
- your WiFi network, so you can reach the board for the first install
SSH in and run:
sudo apt update && sudo apt install -y git
git clone https://github.com/anducampu/SAFYDO.git
cd SAFYDO
sudo ./install.sh
sudo rebootThat is the whole install. It is idempotent — re-run it any time to update or repair a box.
It takes a while. The Bluetooth HD-codec stack is compiled from source: budget roughly 30–45 minutes on a Zero 2 W and 20–30 on a 3 A+. The difference is CPU speed, not the OS — the AAC encoder has no package on Raspbian armhf on either release, so it is built either way. Everything else takes a few minutes. Use
--dry-runfirst to see the plan without touching the system.
After the reboot, open http://safydo.local from any machine on the same network. If mDNS does
not resolve, use the board's IP address.
sudo ./install.sh # full first-time setup
sudo ./install.sh --dry-run # print every action, change nothing
sudo ./install.sh --update # refresh packages and redeploy configs
sudo ./install.sh --no-upgrade # skip the OS dist-upgrade
sudo ./install.sh --keep-onboard-wifi # never touch the onboard radio
sudo ./install.sh --help # all flagsUseful environment variables:
| Variable | Effect |
|---|---|
SAFYDO_HOSTNAME=name |
mDNS name. Optional — boards that collide now rename themselves (see §8) |
SAFYDO_KEEP_SERVICES="a b" |
Units the slimming pass must leave alone |
APPLY_OVERCLOCK=1 |
Opt into the per-board overclock |
PURGE_PRINTING=1 |
Remove printing packages instead of just masking them |
Everything below is in the web UI; no SSH needed.
- Power on the speaker and put it in pairing mode.
- Open
http://safydo.local→ Bluetooth → Scan. Classic speakers take 8–16 s to appear, so let the scan finish before assuming it failed. - Click your speaker → Pair. It becomes the default output and reconnects automatically at boot.
- Pick a station and press play.
The Bluetooth panel shows the negotiated codec and bitrate, so you can confirm you are getting the best your speaker supports.
If pairing does not survive a reboot — the panel shows
Paired: yesbutBonded: no— the stale key is on the speaker, not the Pi. Factory-reset the speaker and pair again. Retrying from the Pi will not fix it.
SAFYDO ships with a starter station list, and you can pull in thousands more from two built-in directories. Both are off by default — enable them under Settings → Sources, and they appear as extra tabs in the station browser.
Radio Browser — a free, community-maintained directory of public Icecast/Shoutcast stations. No API key, no account. Browse by genre tag, search by name, or sort by popularity. SAFYDO rotates across several mirrors so one being down does not break browsing, and it hides streams the directory has already flagged as broken.
Jamendo — Creative-Commons licensed music from independent artists,
free to stream. This one needs a free client_id from
developer.jamendo.com — paste it into Settings → Sources. Browse
by genre; tracks stream as MP3.
Anything you find plays immediately, and one tap saves it into your own station list so it is there next time.
Both providers are plain HTTP clients in backend/sources/, so adding another directory means
dropping in one more file.
It modifies the OS, so it is worth knowing what it does. All of it is reversible and none of it is
hidden — read install.sh, or run --dry-run.
- Upgrades the OS on every run (
--no-upgradeopts out) - Installs bluez, mpv, nginx, avahi, python3-fastapi/uvicorn/pydantic, ntfs-3g, exfatprogs, udisks2 and friends
- Builds bluez-alsa from source into
/usr/localfor the HD codecs Debian's package omits - Disables background services that cost RAM or wake the SD card: apt-daily timers, man-db, e2scrub, ModemManager, cloud-init, printing. All
disable, nevermask, so it stays reversible; exempt any unit withSAFYDO_KEEP_SERVICES - Tunes boot and kernel —
gpu_mem=16, network buffers, tmpfs for logs and/tmp. Fragments live indocs/os-tuning/so you can read exactly what is applied - Disables the onboard WiFi only under the safety rule in §1
- Installs systemd units for the backend, player, Bluetooth auto-connect and USB automount
A Pimoroni Blinkt! — eight RGB LEDs on a 2x20 header — turns the player into something you can read across a room. SAFYDO drives it as a centre-out stereo VU meter: the middle LEDs light first and the bar grows outward with the music, green through amber as it gets loud. That is the strip you can see lit in the photo above.
It presses straight onto the GPIO header — no soldering, no extra wiring, and it does not block the
USB or HDMI ports. SAFYDO uses GPIO 23/24 via lgpio, which the installer sets up for you.
It is off by default. Turn it on in the web UI under Settings → LEDs, where you can also set brightness (1–100%). With no Blinkt! attached, nothing happens and nothing breaks.
config/player.yaml holds the runtime settings — volume, EQ, auto-reconnect strategy, LED toggle,
station-source plugins. Most are editable from the UI's Settings panel. Stations live in
config/streams.json and can be added, edited or bulk-replaced from the UI.
The uplink is always a USB dongle (the onboard radio is off so Bluetooth owns 2.4 GHz), so swapping ethernet for WiFi is a normal thing to do. Adapters are hot-swappable and the box keeps itself tidy:
- WiFi dongle in — the WiFi radio is switched on and a saved network is joined automatically. Profiles are not tied to an adapter, so a network learned on one dongle works on the next.
- WiFi dongle out — with no wireless adapter left, the radio is switched off rather than left scanning for hardware that is gone.
- Either way — route metrics are re-evaluated, so wired still beats wireless whenever both are present.
If the box has no uplink and no known network, it raises its own setup access
point — Safydo at 192.168.4.1 — so you can join a new WiFi from a phone.
This is on by default, because on a fresh board it is the only way in without
a serial cable. It needs a wireless adapter to host the AP, so an ethernet-only
box never raises one. Disable it with captive_portal_enabled: false in
config/player.yaml, or start it by hand:
sudo /opt/rpi-audio-player/scripts/captive_portal.sh startIt waits for several consecutive checks before acting, so a slow DHCP lease or a dongle still enumerating never triggers it, and it stands down by itself once a real uplink appears.
The setup page lists the networks it can see, with signal strength and a lock for secured ones, so you pick yours instead of typing its name from memory — "Other network…" is still there for a hidden SSID. Picking an open network does not ask for a password.
The list is a snapshot taken just before the AP started, and the page says so. A board with one radio cannot broadcast the setup network and scan for other networks at the same time, so refreshing while the AP is up re-reads that snapshot rather than sweeping again. If a network you expect is missing, it was not on the air at that moment — type it in.
The setup AP is open (no passphrase), like most first-run provisioning networks — you cannot type a password into a box you have not configured yet. It exists only while the board has no uplink, and anyone joining it reaches the setup page, so bring the board online rather than leaving it advertising.
On radio interference. Bluetooth on these boards is wired to the UART, not USB, so a USB WiFi dongle does not compete with audio for USB bandwidth. What can still bite is 2.4 GHz coexistence — much weaker than the onboard radio, which shared a die and an antenna with Bluetooth, but not zero. A 5 GHz-capable dongle avoids the question entirely and is the one to buy if you plan to run on WiFi. Wired remains the most reliable.
Two SAFYDO boxes on one network would both want safydo.local. Rather than letting avahi break the
tie with a counter — which assigns safydo-2.local in whatever order the boards happen to boot, so
the same box changes name between reboots — a newly connected box detects that the name is taken and
claims one derived from its own address instead:
first box -> safydo.local
second box -> safydo-119.local (10.0.0.119)
The suffix is the last octet of the box's own IP, which is unique within a subnet, so two boards can
never pick the same name. This runs at boot, and only when a conflict is actually detected — a box
alone on the network always keeps the plain name. A name you chose yourself (SAFYDO_HOSTNAME=kitchen)
is never overridden.
Check what a box thinks at any time:
sudo /opt/rpi-audio-player/scripts/hostname_conflict.sh statusThe DHCP caveat. The suffix follows the IP, so if the lease changes, the name changes with it. For a box you want to bookmark, give it a DHCP reservation on your router, or name it explicitly with
SAFYDO_HOSTNAME=at install time. The automatic suffix is there to stop collisions, not to be a stable address.
The UI has no authentication — it assumes a trusted home LAN, and it binds to the LAN rather than the internet. Do not port-forward it.
There is an optional token guard on mutating endpoints, but it is inert until you enable it: the
backend only checks X-Safydo-Token when PLAYER_API_TOKEN is set in its environment. Sending the
header without that is not protection. To turn it on:
sudo systemctl edit audio-player-backend # [Service] Environment=PLAYER_API_TOKEN=<secret>
sudo systemctl restart audio-player-backendUntil then every mutating endpoint — including shutdown and Bluetooth pairing — is open to anyone on the network.
The Pi has no Node toolchain — a 512 MB board cannot run a Vite build — so frontend/dist/ is
committed and a clone installs a ready-built UI. Rebuild it on a dev machine:
cd frontend && npm install && npm run buildDeploy a working tree to a running board:
PI_HOST=<pi-ip> ./scripts/deploy.sh --buildLive UI development against a real board:
SAFYDO_DEV_HOST=<pi-ip> npm run dev| Symptom | Start here |
|---|---|
| Install finished but the UI is blank | Re-run the installer; it prints INSTALL INCOMPLETE and what is missing |
| No sound, "no Bluetooth speaker connected" | Speaker off or unpaired — pair it in the UI |
| Pairing lost on every reboot | Factory-reset the speaker (see §4) |
| Audio stutters | You are probably on the onboard 2.4 GHz WiFi — add a USB dongle |
| Locked out after WiFi was disabled | sudo scripts/wifi_onboard.sh enable, or the recovery routes in docs/usb-dev-link.md |
On-device diagnostics: sudo /opt/rpi-audio-player/scripts/diag.sh
More detail lives in docs/ — phase0.md through phase7.md cover each subsystem, and
porting-pi3aplus.md documents the board and OS differences.
Feature-complete and running on both supported boards: Bluetooth pairing with HD codec selection, internet radio, USB and network-share playback, EQ with presets, a live stereo VU meter, a captive portal for WiFi setup, USB automount and safe shutdown.
Known limitations:
- Some speakers advertise SBC only — SAFYDO then negotiates SBC-XQ (~570 kbps), which is the best that hardware can do
- The captive portal assumes an onboard
wlan0and will not run on awlx*USB adapter - AAC is denylisted for a few devices whose firmware stalls when switching codecs
GPLv3 — see LICENSE. Contributions welcome.

