Synchronized multi-room audio for Raspberry Pi, built on the Sendspin protocol.
Plum-Audio turns a set of Raspberry Pis into a synchronized multi-room audio system. Each unit is a receiver — it accepts AirPlay, Spotify Connect and Bluetooth from your phone or laptop — and a speaker, and any source can be routed to any set of speakers across the mesh, in any combination, at the same time.
Send AirPlay to the kitchen from your phone while Spotify plays in the living room, then pull the kitchen speaker into the living room group without either stream stopping. There is no central server: every unit runs its own, and they find each other on the LAN.
Album art, metadata and the visualizer travel out-of-band on their own protocol roles rather than riding the audio stream, so artwork changes and track updates cannot disturb playback.
- Multi-room sync — sample-accurate playback across units via Sendspin, an Open Home Foundation protocol.
- Sources — AirPlay, Spotify Connect and Bluetooth A2DP, each supporting multiple named endpoints per unit.
- Cross-routing — move a speaker to any source on any unit. Re-routing within a unit is live; moving between units is a reconnect the jitter buffer covers, so it is inaudible.
- Groups — multiple concurrent groups, per-speaker and per-group volume, plus source-level volume (the sending device's own level).
- Web GUI per unit — served from the unit itself on port 80. Now-playing with artwork, an audio-reactive spectrum visualizer, optional album-art UI theming, and full settings.
- Interoperable — advertises over mDNS as a standard Sendspin server and player, so Music Assistant and other Sendspin controllers can drive it.
- Headless units — a unit with no sound card still works as an ingest and routing node.
- One container per unit — everything runs inside it.
- Raspberry Pi 4 (or better) running Debian 13 "trixie" — 64-bit Raspberry Pi OS Lite is the
tested base. Debian 13 specifically:
bluez-alsaandshairport-syncdiffer meaningfully on 12. - Docker with host networking. mDNS needs layer 2, so bridged networking will not work, and all units must be on the same subnet to discover each other.
- The host's Avahi and D-Bus (the container uses them rather than running its own).
- An audio output — the Pi's 3.5 mm jack, a HAT, or a USB DAC. Optional; see headless units above.
- Images are built for
linux/arm64only. See Other platforms. - Each Pi needs a route to the internet for its first deploy —
deploy.shinstalls Docker overapton a fresh card. After that the units only need each other and your LAN.
Only for the fleet path. Commissioning a single Pi with scripts/plum-init.sh needs none of this —
it runs on the Pi and pulls a published image.
| Need | Why |
|---|---|
| Docker | builds the image. On Apple Silicon the arm64 build is native; on an Intel Mac or x86 Linux you must register QEMU/binfmt first or build.sh fails with an exec-format error |
git, bash, ssh/scp |
the tooling is shell scripts driven over SSH |
sshpass |
every script authenticates non-interactively and refuses to start without it |
sshpass is no longer in homebrew-core (it was removed as a security risk), so the obvious
brew install sshpass fails. On macOS use:
brew tap hudochenkov/sshpass
brew install sshpassTwo paths, and they produce the same unit. Pick by how many Pis you have in front of you.
| One Pi | A fleet | |
|---|---|---|
| Runs on | the Pi | your workstation, over SSH |
| You supply | the device name | a table of every unit |
| Needs a checkout of this repo | no | yes |
Needs sshpass |
no | yes |
| Image comes from | the public registry | your own build.sh, or the registry |
The device name is the only thing you choose. The unit derives its own mesh id from its hostname, detects its own sound card, and writes its own config.
1. Flash and boot Raspberry Pi OS Lite (64-bit, Debian 13).
- Enable SSH and create a user in Pi Imager, so you can get a shell.
- Set a unique hostname per unit. The unit derives its mesh id from it. Two Pis both called
raspberrypiclaim one id, and two units on one id corrupt each other's routing. - Configure WiFi in Imager if the unit has no ethernet — a headless Pi with neither never appears on the network.
- Give the unit a static address or a DHCP reservation.
2. Run the installer on the Pi. It needs internet for this run, to install Docker and pull the image.
curl -fsSLO https://raw.githubusercontent.com/AnotherMike-exe/Plum-Audio/main/scripts/plum-init.sh
chmod +x plum-init.sh
sudo ./plum-init.sh "Kitchen"Allow about 10 minutes on a fresh card. Most of that is the Docker install and the image pull. Add
--check first if you want a report of the host and no changes.
Testing an unreleased branch. The
curlline above and the default:latestimage both trackmain, which is the last release. Do not mix a branch script with amainimage: they can differ by anaiosendspinmajor, and two units across such a split cannot mesh at all.Every push to
devpublishes:devfrom that same commit, so pull it rather than building:scp scripts/plum-init.sh plum-admin@<pi>: # the script is not on main yet ssh plum-admin@<pi> sudo ./plum-init.sh "Kitchen" --image ghcr.io/anothermike-exe/plum-audio:devThat image carries the host-setup payload, so nothing is fetched from GitHub. Use
--tarballwith a localdocker/build.shonly for work you have not pushed, or for a unit with no internet.
3. Write down the fleet pairing secret it prints. The first unit mints one. Every later unit must get the same value, or the units cannot pair with each other's speakers:
sudo ./plum-init.sh "Living Room" --fleet-psk <the value the first unit printed>Adding a unit later and no longer have the value? Any running unit still holds it. Point the new unit at one and it copies the secret over ssh:
sudo ./plum-init.sh "Bedroom" --fleet-psk-from 192.0.2.104. Open http://<unit-ip>/. Add your sources under Settings → Integrations. The audio output is
already picked, and Settings → Audio is where you change it.
What it decides for you, and how to override each one:
| Decision | How | Override |
|---|---|---|
| Mesh unit id | from the hostname, or kept as-is on a re-run | set the hostname in Imager |
| Audio output | reads the Pi's cards, preferring a HAT or USB DAC over the onboard jack over HDMI | --output <PortAudio name fragment> |
| Audio or headless | headless when the Pi has no sound card | --output none forces headless |
| Which image | ghcr.io/anothermike-exe/plum-audio:latest |
--image <ref> or --tarball <file> |
| Fleet secret | mints one on the first unit | --fleet-psk <value> |
Two things stay opt-in, because neither can be inferred:
-
An audio HAT needs the overlay and, separately, unity gain. These are two passes with a reboot between them — the second needs the card to exist, and the card does not exist until the overlay has been applied and the Pi rebooted:
sudo ./plum-init.sh "Kitchen" --overlay hifiberry-amp100 sudo reboot # the script does NOT reboot for you sudo ./plum-init.sh "Kitchen" --unity
Run together they silently do nothing useful:
--unityfinds no card, and the HAT is left ~22 dB quiet with every volume slider reading correctly. -
--with-bluezbuilds a patchedbluetoothd(~30 min) that polls AVRCP play status, which is what makes Bluetooth scrub position report correctly. Skip it if you do not need Bluetooth metadata.
A unit on the Pi's onboard 3.5 mm output needs neither.
The script is re-runnable. A second run keeps /opt/plum-audio/{config,data}, so settings.json,
your Spotify authorizations and this unit's mesh identity all survive. Re-run it to move a unit to
a new image.
The script reads its host-setup files out of the image it pulls, so a Pi needs no copy of this repo. An image built before the installer existed does not carry them, and it falls back to fetching them from GitHub. If the Pi can reach neither, point it at a checkout with
--payload-dir <path>.
Use this to deploy several units together, or to run an image you built yourself. Everything is driven from your workstation over SSH. A Pi needs no copy of this repo, but it does need internet access the first time, to install Docker.
1. Flash and boot every Pi, exactly as in step 1 above. The scripts default to the user
plum-admin; if you use another name, set PLUM_TEST_USER in step 2 — nothing else will tell you
why every connection is refused. Put every unit on one subnet: mDNS is link-local, so units on
different VLANs cannot discover each other.
2. Describe your fleet — on your workstation, once for all units.
Nothing in this step touches a Pi. You are writing two files on the machine you deploy from, and you write them one time, not once per unit.
git clone https://github.com/AnotherMike-exe/Plum-Audio.git
cd Plum-Audio
cp docker/units.conf.example docker/units.conf # then edit: one line per unit
printf 'PLUM_TEST_USER=%s\nPLUM_TEST_PW=%s\n' 'plum-admin' '<your pi password>' > docker/.deploy.env
chmod 600 docker/.deploy.envBoth files are gitignored. units.conf is two columns, and only one of them is a choice:
192.0.2.10 | Kitchen
192.0.2.11 | Living Room
192.0.2.12 | Office | snd_rpi_hifiberry_dacplus
| Column | What |
|---|---|
| host | The unit's IP address or hostname. |
| name | What the unit is called. Keep names unique — a duplicate is suffixed with the Pi's SoC serial and warned about, not refused. |
| audio output | Optional. Left out, deploy.sh reads the unit's cards and picks one. It is a PortAudio name fragment, not an ALSA address. none makes the unit headless. |
The ids this table used to carry are gone. The container derives the unit id from the Pi's hostname
and the player id from the unit id, and deploy.sh keeps whatever id a unit is already running
under — so a redeploy never renames a live unit. A six-column file still works, with a warning.
Write
.deploy.envonce. The firstdeploy.shrun mints a fleet pairing secret and appendsPLUM_FLEET_PSK=…to this file. Every unit must share it. To fix a typo, edit the file — never re-run the>redirect above, which would truncate the secret away.Losing this file does not lose the secret: every unit holds a copy, and
deploy.shasks them for it before it will mint a new one. If any unit is unreachable at that moment it refuses to mint at all, because a second secret splits the fleet without ever reporting an error. Full detail: docs/SENDSPIN-PAIRING.md.If your password contains a space,
$,'or#, quote it: the file issourced.
3. Provision each Pi — once per SD card image, not per deploy. Full detail and the by-hand equivalent of every step: docs/HOST-PROVISIONING.md.
scripts/host-setup/provision.sh all --check # report what is missing, change nothing
scripts/host-setup/provision.sh all # rfkill, bluez config, D-Bus policies, host nginxall means every row in your units.conf. The audio HAT and the patched bluetoothd are opt-in
here for the same reasons, and with the same two-pass reboot rule, as the single-Pi path above:
scripts/host-setup/provision.sh <ip> --overlay hifiberry-amp100
ssh plum-admin@<ip> sudo reboot # the script does NOT reboot for you
scripts/host-setup/provision.sh <ip> --unity
scripts/host-setup/provision.sh all --with-bluez # ~30 min per unit4. Build and deploy.
docker/build.sh # arm64 image -> dist/plum-audio-<tag>-arm64.tar.gz
docker/deploy.sh all # every unit in units.conf
docker/deploy.sh 192.0.2.10 # or just onebuild.sh produces a tarball rather than pushing to a registry. deploy.sh copies it over SSH and
docker loads it. On Apple Silicon the arm64 build is native — no QEMU. To deploy a published
image instead of building one, use docker/deploy.sh all --pull.
5. Open http://<unit-ip>/. Add your sources under Settings → Integrations and check the output
under Settings → Audio. A freshly deployed unit offers AirPlay only, with Spotify and Bluetooth
switched off until you configure them — that is correct, not a fault. What a greenfield unit does
and does not start with is in
docs/OPERATIONS.md.
Re-run provisioning only after re-flashing a card. Redeploys are just steps 4 and 5, and they leave
settings.json and your Spotify authorizations intact.
Do this whichever path you took. It is the part neither script can verify: each one checks a unit in isolation, so a unit can pass every check and still be alone on the network.
curl -s http://<unit-a-ip>/api/mesh/view \
| python3 -c 'import json,sys; print([u["unit_id"] for u in json.load(sys.stdin)["units"]])'Every unit should be listed, and asking another unit should give the same answer. Allow ~10 seconds: peers announce on a 2 s beacon with an 8 s expiry, so a unit that has just booted takes a moment to appear. If only one is listed, they are not on the same layer-2 segment. mDNS will not cross a VLAN boundary.
Released images are published to GitHub Container Registry:
docker pull ghcr.io/anothermike-exe/plum-audio:latest # or :1.0.0, or :dev:latest tracks the newest release, :dev the dev branch. plum-init.sh pulls :latest unless
--image says otherwise. deploy.sh deploys your own local build unless you pass --pull or
--image.
Settings → Updates. Tick the units to update, pick a channel, press Update.
The page defaults to the unit you are looking at, so you can test a new image in one room before the house follows. Press All when you are ready for the rest. A unit that is already current is left playing — nothing restarts unless there is something new to install.
Each unit is unreachable for about ten seconds while its container restarts. The row says
Restarting. That is the update working, not a fault.
Keep the fleet together. A 9.x client cannot reach a 6.0.5 server, so units split across an
aiosendspinmajor cannot sync at all. The page warns when a selection would leave reachable peers behind. Read that warning before you update one room and walk away.
On the unit:
cd /opt/plum-audio
docker compose pull && docker compose up -dOr drive the host agent directly, which is what the GUI does:
sudo plum-updater.sh check # refresh "what is available", install nothing
sudo plum-updater.sh update # pull and restart now
sudo plum-updater.sh update latest # switch channel and update
sudo plum-updater.sh state # print what it last didFrom your workstation, for the whole fleet at once:
docker/deploy.sh all --image ghcr.io/anothermike-exe/plum-audio:devA container cannot replace itself — the process that would pull a new image runs inside the thing
being replaced. Mounting the Docker socket would fix that and open a much worse hole: these APIs are
unauthenticated and bound to 0.0.0.0, so a socket inside the container is root on the host for
anyone on the VLAN.
So the work happens on the host, and the two halves pass files through the /config bind mount:
GUI ──POST /api/mesh/update──▶ container writes /config/update.request
│
systemd path unit sees the file
▼
plum-updater.sh: docker compose pull
then up -d, only if the pull worked
│
writes /config/update.state ──▶ the GUI polls it
The state file lives on the bind mount so the result survives the container restart that ends the request.
The agent is installed by provision.sh, once per Pi image. A unit without it works perfectly
and updates by hand — the Updates tab reports its host as unprovisioned and refuses to pretend
otherwise. Units commissioned before the agent existed are in exactly this state.
A daily timer checks the registry and never installs anything. Applying an update is always a deliberate act, because a timer that applied them would split the mesh across a protocol major while nobody was watching.
Nothing in the application is Pi-specific, but the deployment tooling is, and amd64 has never been
built or run. To deploy Plum-Audio anywhere else, treat docker/docker-compose.yml as the
contract rather than using deploy.sh:
PLUM_PLATFORM=linux/amd64 docker/build.sh # untested — expect to fix thingsWhat any host must provide, whatever it is:
| Requirement | Why |
|---|---|
| Host networking | mDNS discovery is link-local; a bridge network breaks it. |
| Debian 13 base | bluez-alsa and shairport-sync package layouts are matched to it. |
| Host D-Bus socket and host Avahi | AirPlay metadata is MPRIS over D-Bus; mDNS goes through the system Avahi so the unit is discoverable by third-party controllers. |
/dev/snd |
Unless the unit is headless, in which case use the compose headless profile — Docker refuses to create a container whose devices: names a missing /dev/snd. |
/proc/asound bind-mounted at /host/asound |
It is masked inside a container, and card enumeration needs it. |
Persistent /config and /data |
Settings, Spotify credentials and player state. |
Ports used: 80 (GUI), 8927/8928 (Sendspin server/player), 8929 (mesh discovery), 5001 (mesh API), 5002 (config API), 5050+ (AirPlay RAOP), 5354+/3678+ (Spotify). The host's Avahi owns 5353. Full detail in docs/ARCHITECTURE.md.
Bluetooth additionally needs the host-side BlueZ configuration from docs/HOST-PROVISIONING.md; none of it is installed by the container.
Source (AirPlay / Spotify / Bluetooth)
-> daemon -> FIFO -> PushStream feeder
-> in-process Sendspin server (group + stream)
-> Sendspin players: this unit's speaker, plus any roamed remote players
Metadata / artwork / visualizer -> separate Sendspin roles, off the audio path
The mesh model is "servers stay, players roam." Every unit runs both a server (ingesting its own sources) and a player (its speaker). Cross-routing moves the player to another unit's server; audio is never bridged between servers. See docs/ARCHITECTURE.md.
| Doc | What it is for |
|---|---|
| ARCHITECTURE.md | Mesh model, process model, subsystem design |
| OPERATIONS.md | Build, deploy, debug — including the deceptive failure modes |
| HOST-PROVISIONING.md | Commissioning a new Pi, step by step |
| HARD-WON-LESSONS.md | Why the code is shaped this way. Read before "simplifying" |
| OPEN-ITEMS.md | Known gaps, deferred calls, and resolved-with-history |
| SPEC-CONFORMANCE.md | Where we stand against the Sendspin spec |
| SENDSPIN-PAIRING.md | Encryption/pairing: what we do instead, and what it costs |
| ROUTING-MODEL.md | Proposal — unified attach/detach and the true-none rule |
| VOLUME-CALIBRATION.md | Per-endpoint loudness curves and matched multi-room volume |
| UPSTREAM-AIOSENDSPIN.md | Workarounds to delete when the pin bumps |
| AIOSENDSPIN-BUMP-SCOPE.md | 6.0.5 → 9.1.0 scoped, and why the pin is on hold |
| PHASE-HISTORY.md | What shipped when, and what hardware proved it |
| TESTING.md | Test tiers, and what is not yet reproducible |
| CONTRIBUTING.md | Branches, commits, versioning, style |
- Backend — Python 3.13,
aiosendspin(pinned 9.1.1), PyAV, NumPy, Flask + aiohttp, supervisord - Frontend — React 19, TypeScript 5, Vite 6, served by nginx inside the container
- Base image —
python:3.13-slim-trixie. glibc rather than Alpine, deliberately: PyAV, PortAudio and NumPy all have trivial wheels there
1.0.0. AirPlay, Spotify and Bluetooth are hardware-validated across four units, as are the mesh (discovery, aggregation, roaming, multiple groups, per-player volume), interop against Music Assistant, the container build, and audio output selection.
Known gaps, stated plainly:
- DLNA and Plexamp have no backend. Settings stubs and GUI scaffolding exist; nothing is wired up.
- amd64 has never been built.
- The APIs are unauthenticated. Both bind
0.0.0.0with permissive CORS. Any page on your LAN can change a unit's settings. Run this on a trusted network. - Remaining spec gaps are tracked in SPEC-CONFORMANCE.md.
Plum-Audio is mostly integration work. It stands on:
- Sendspin and
aiosendspin(Open Home Foundation) — the synchronization protocol and server library. This project is a consumer of the spec, not affiliated with it. - shairport-sync by Mike Brady — AirPlay reception, and the MPRIS interface the metadata comes from.
- go-librespot by devgianlu — Spotify Connect.
- BlueZ and bluez-alsa by Arkadiusz Bokowy — Bluetooth A2DP and AVRCP.
- PyAV, PortAudio /
sounddevice, NumPy, Pillow — the audio and image pipeline. - ColorThief and react-colorful — album-art theming.
- Snapcast by Johannes Pohl — the sync engine behind Plum-Snapcast, this project's predecessor. Plum-Audio replaces it with Sendspin, but the source integrations and GUI were ported from that work rather than rewritten, and the architecture it taught is throughout this one.
Each of these keeps its own license; they are invoked as separate processes or linked by their own terms, not relicensed here.