Building a multi-room home audio system begins by making “dumb speakers smart.” Like with Sonos, these DIY wireless receiver(s) can be grouped together and play music from many different sources using pulse audio + snapcast.
This post will explain how to use Raspberry Pi audio output to connect two or more speakers together in perfect harmony.
It’s part of the audio section of the DIY smart home retrofit project which shows how to make speakers wireless.
Well, I say “Raspberry Pi audio output…” but this approach should work with any Debian-based linux system to create a DIY wireless stereo system. A single computer can theoretically control several different speakers. A Raspberry Pi 4B, for example, has one aux output, two HDMI outputs, four USB outputs, and bluetooth.
In the smart home network design series, I discussed some of the advantages of having several Raspberry Pis scattered throughout the house. Now, we can use those devices to create “Pi speakers” from whatever speakers you happen to have. For example, we had two different Bluetooth speakers (from our van travels) plus an HDMI sound bar for the TV.
Ultimately, even the TV will make use of this multi room audio system (instead of going directly to the soundbar). This approach to a wireless receiver will also support many different sources, from Spotify to Airplay. But, I’m getting ahead of myself…
First, let’s look at how it all works.
This has also been tested on Ubuntu 18.x
… and any Debian system should work.
Multi-Room Wireless Receivers
Snapcast can centralize broadcasting of audio streams.
Snapcast does not actually handle the playing of music. Rather, it handles sending audio streams to wireless receivers to create a multiroom wireless speaker system. On linux computers, audio streams are often represented as fifos.
These fifos appear as files (like /tmp/snapcast
), each of which is just a stream of data. The trick to make speakers wireless is to broadcast this stream to each speaker. With snapcast, many different clients can connect to the same server in order to stream the same audio. What makes snapcast special is that it also allows you to group speakers together, as well as adjust latency on each speaker.
- snapserver is the wireless audio transmitter.
- snapclient(s) are the wireless audio receiver(s).
Consider the following wifi speaker adapter diagram:
A single server provides the audio feed. In fact, to create a whole house sound system you can set up more than one feed from the server, and there may be times when it makes sense to have more than one server — more on that in later posts. For now, the best wireless surround sound can be achieved by positioning the speakers so that the sound comes from everywhere at once.
Each audio stream is a fifo (file) on the server.
These files are located at/tmp/snapclient-*
. Writing data to them will cause the snapserver to broadcast the audio stream to all the snapclients.
To stream music to a stereo receiver, start by grabbing the latest releases of both the snapclient and snapserver (for the Raspberry Pi, grab the armhf
variant). The setup guide is definitely worth reading. You can run both the client and server on a single linux machine for now, if that makes things easier (placing them on separate machines is what creates the “wireless receiver” bit). Begin by running the snapserver
command to get the server up and running.
For the docker users, here are some sample deployment files for running a snapserver on IOT Kubernetes. You’ll note:
- Ports
1704
,1705
, and1780
for the snapserver. - Fifos are mounted from the host at
/tmp
for cross-container communication. - Affinity is limited to the
big-box
, so it always runs on that machine. - Config files mounted at
/etc/snapserver.conf
and/.config/snapserver
.
apiVersion: v1 kind: Service metadata: name: snapserver spec: type: ClusterIP selector: app: audio audio: server ports: - port: 1704 name: snap-stream targetPort: snap-stream - port: 1705 name: snap-control targetPort: snap-control - port: 1780 name: snap-http targetPort: snap-http
apiVersion: apps/v1 kind: DaemonSet metadata: name: snapserver spec: selector: matchLabels: app: audio audio: server template: metadata: labels: audio: server spec: hostNetwork: true containers: - name: snap-server image: ivdata/snapserver imagePullPolicy: IfNotPresent ports: - containerPort: 1704 name: snap-stream - containerPort: 1705 name: snap-control - containerPort: 1780 name: snap-http volumeMounts: - name: audio-data subPath: config/snapserver.conf mountPath: /etc/snapserver.conf - name: audio-data subPath: config/snapserver mountPath: /.config/snapserver - name: tmp mountPath: /tmp env: - name: HOST_SNAPCAST_TEMP value: /tmp volumes: - name: audio-data persistentVolumeClaim: claimName: audio - name: audio-conf configMap: name: audio - name: tmp hostPath: path: /tmp affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: "kubernetes.io/hostname" operator: In values: ["big-box"]
Now it’s time to start connecting different speakers.
Audio Receiver(s)
The next tool to know is alsa
.
Alsa is your low-level sound card integration.
Open up a new terminal window or connection to one of the clients.
The devices which are available to Snapcast are determined by Alsa. If you run snapclient -l
on the device with the snapclient installed, you will see a list of possible outputs. If you type aplay -l
on that same device, you’ll see a very similar list.
If you have a .wav file handy (here are some), you can play it with aplay myfile.wav
. You’ll need something connected to the aux output (headphones will work).
If the desired speakers are not the default output, the correct device can be chosen with the -D hw:X,Y
flag. X
and Y
are the device and subdevice numbers found from aplay -l
. Typically, the aux output is on 0,0 (which is also the default), effectively the same as aplay -D hw:0,0 myfile.wav
.
For aux speakers, I was impressed by the depth of sound and cheap price tag from the Pebbles (below). We now have two sets of these in the house. Of course, they don’t have the kick of the HDMI soundbar, but they add a lot to the acoustics of the room.
Preview | Product | |
---|---|---|
Creative Pebble 2.0 USB-Powered Desktop Speakers with Far-Field Drivers and Passive Radiators for... |
On the Raspberry Pi 4, there are two HDMI outputs. The aplay -l
command lists them at 1,0
and 2,0
. It is also possible to find pure USB speakers, such as the following Logitech speakers. What I like about these is the ability to use a single USB extension cable and place them in a hallway or nearby area.
Preview | Product | |
---|---|---|
Logitech S150 USB Speakers with Digital Sound |
Why so many speakers?
This wireless receiver system also supports the doorbell sound and audio safety alerts, not to mention the TV. More on all that in a later post.
So far, we’ve played audio with alsa. To actually test that snapcast works, run the snapcast -l
command to find the appropriate speakers. Note that snapcast uses a simple integer X
instead of the X:Y
format. You’ll also need the IP address of a server to connect to… and I’d recommend naming the speakers which are doing the connection. For example, if the snapserver is running on the same device, and you wish to connect speakers named kitchen located on snapclient device 1:
snapclient -h 127.0.0.1 --hostID kitchen -s 1
You should see a successful connection to the server printed in the output. You can now play the same wav file by piping it into the snapfifo on the server:
cat myfile.wav > /tmp/snapfifo
You can connect a second audio output on the same device (or a different device, using the correct IP address) by repeating the same snapclient command with different values for --hostID
and -s
.
With more than one set of speakers, snapclient can finally shine.
Located at .config/snapserver/server.json
on the server are the data that define the speaker groups. Each speaker within a group also has a latency. You can manually edit these values and restart the snapserver. Tuning the latencies this way is hard, though, so it’s easier to use a tool that can interface with the snapserver directly.
Home Assistant supports snapcast as a media player.
You can adjust the latencies and group/ungroup speakers directly from Home Assistant services. More on that, and other aspects of Home Assistant integration, in the audio series.
Pulse Audio + Snapcast + Bluetooth
The trouble with Bluetooth speakers comes with latency and pairing.
Bluetooth speakers will generally suffer an additional delay as compared to speakers connected via wires. Thankfully, snapcast solves the latency bit.
Bluetooth latency is why it’s a good idea to run a snapclient for each of the speakers.
… as opposed to combining two speakers at the PulseAudio level, which precludes per-device latency settings on the snapserver.
Unfortunately, alsa (and therefore the snapclient) do not know how to speak to Bluetooth (bluez-alsa may work, but has a lot of dependencies). I ultimately found it easier to connect Bluethooth speakers via PulseAudio, which is a “layer above” alsa.
To do so, start by opening up another terminal or connection to the client running the bluetooth speakers. Install the required bluetooth and PulseAudio utilities:
sudo apt-get install --no-install-recommends bluetooth bluez blueman pulseaudio pulseaudio-module-bluetooth
In a moment, we will run PulseAudio… but we want it to have access to Bluetooth. There are a couple possibilities, such as running them both as root. Instead, I prefer to run them both as the current user (pi). This requires giving the pi user access to Bluetooth by editing /etc/dbus-1/system.d/bluetooth.conf
; add the following before the closing tag:
<policy user="pi">
<allow send_destination="org.bluez"/>
<allow send_interface="org.bluez.Agent1"/>
<allow send_interface="org.bluez.GattCharacteristic1"/>
<allow send_interface="org.bluez.GattDescriptor1"/>
<allow send_interface="org.freedesktop.DBus.ObjectManager"/>
<allow send_interface="org.freedesktop.DBus.Properties"/>
</policy>
Now when you start bluetoothctl
you can scan for the sound device:
agent on
default-agent
scan on
When you find the appropriate device’s MA:CA:DD:RE:SS
:
pair MA:CA:DD:RE:SS
trust MA:CA:DD:RE:SS
connect MA:CA:DD:RE:SS
Once you’ve connected to the device, it’s time to start pulseaudio
(you can add --log-level=debug
to debug problems, or -d
to run it as a daemon). Then you can run pactl list sinks
to see what output devices (sinks) are avaible.
Ideally, the Bluetooth device will already show up. If not, one fix that frequently works for me is to restart pulseaudio and then re-connect the bluetooth device. You can do the latter via the one-liner:
echo -e "connect $BLUETOOTH_MAC\nquit" | bluetoothctl
If the bluetooth think still isn’t showing up, first check the Pulse Audio logs. One possibility is that the bluetooth module is not being loaded, because it is not present in either /etc/pulse/default.pa
or /etc/pulse/system.pa
. It should look something like this:
.ifexists module-bluetooth-policy.so
load-module module-bluetooth-policy
.endif
.ifexists module-bluetooth-discover.so
load-module module-bluetooth-discover
.endif
Once you’ve found the sink from pactl list sinks
, it is possible to bridge a sink back to alsa. Simply edit ~/.asoundrc to include
:
pcm.bluetooth {
type pulse
device "THE_SINK_NAME"
}
Now, a new device (bluetooth
) should appear in aplay -l
… and snapclient -l
, allowing you connect it to the snapserver.
Service Files
If you’ve been following along, you likely have three applications running in different terminal windows:
snapserver
: streaming the audio to the clientssnapclient
(s): playing audio to the speakerspulseaudio
: bridging Bluetooth to alsa/snapclient(s)
Snapcast comes with multi-user-target services. It wants to be installed as a root (sudo) service. For running the snapserver, this is fine:
sudo systemctl start snapserver
sudo systemctl enable snapserver
The client is a bit more tricky.
If you are not using PulseAudio and also not using more than one speaker on the wireless receiver, then you can use the default snapclient service. Repeat the above service installation for the snapclient, editing the file at /etc/default/snapclient
to update SNAPCLIENT_OPTS
with your speaker config.
PulseAudio usually comes installed with a user service file at /usr/lib/systemd/user/pulseaudio.service
. This is why Bluetooth was also configured at the user level, above. PulseAudio can be started and enabled for the current (pi) user:
systemctl --user start pulseaudio.service
systemctl --user enable pulseaudio.service
But this leaves a problem. User-services will terminate after the ssh session, by default. The simplest approach disable this behavior system-wide (which is useful for other cases, like tmux). Edit /etc/systemd/logind.conf
and uncomment/add the line KillUserProcesses=no
, and then restart: sudo systemctl restart systemd-logind
. It also helps to enable “linger,” to cover all bases: sudo loginctl enable-linger "$USER"
.
Now that PulseAudio is running as a user service, we need a way to run multiple snapclients as user services. I adapted the built-in snapclient service to the following, which could be placed at /usr/lib/systemd/user/snapclient-kitchen.service
:
[Unit] Description=Snapcast Kitchen Documentation=man:snapclient(1) Wants=avahi-daemon.service After=network-online.target time-sync.target sound.target avahi-daemon.service PartOf=pulseaudio.service [Service] ExecStart=/usr/bin/snapclient -h 192.168.0.100 --hostID kitchen -s 4 Restart=on-failure [Install] WantedBy=default.target
With that, you can systemctl --user enable snapclient-kitchen
and systemctl --user start snapclient-kitchen
. And then repeat the process for each set of speakers on the device. Note that this approach does not rely on any external configuration file. Because there may be more than one speaker, each speaker is given its own service file and ExecStart
command.
The magic happens with the Unit.PartOf
declaration. By declaring itself as PartOf the PulseAudio service, the Snapclient is restarted when the PulseAudio service is restarted. This dependency chain is useful in that the command systemctl --user restart pulseaudio
will also restart snapclient, causing it to pick up any new pulseaudio sinks. This becomes useful with bluetooth…
If you’re using bluetooth speakers, you may also have problems with them disconnecting or not re-connecting after a different device connects. To fix this, I created a simple script that can be run as a cron job. If there is already a bluetooth PulseAudio sink enabled, nothing happens. If it is missing, then PulseAudio will be restarted and the bluetooth device re-connected:
#!/bin/bash export PULSE_RUNTIME_PATH="/run/user/$(id -u)/pulse" if pactl list sinks | grep -q 'bluez' &> /dev/null; then echo "Bluetooth already connected to PulseAudio."; exit 0; fi c=$(echo -e "connect $BLUETOOTH_MAC\nquit" | bluetoothctl) if [ "$c" == *"$BLUETOOTH_NAME"* ]; then echo "Connected to $BLUETOOTH_NAME @ $BLUETOOTH_MAC" else echo "Could not connect to $BLUETOOTH_NAME @ $BLUETOOTH_MAC" echo "$c" exit 1; fi systemctl --user restart pulseaudio
You will need to set (export) the BLUETOOTH_MAC
and BLUETOOTH_NAME
.
Part List & Next Steps
Here’s everything I used in my home system:
Now, it’s time to connect the wireless receivers so that they play some real music — not just these test wav files. The next post covers streaming audio sources, before moving on to some cool uses of audio alerts…
OK… this has been the most helpful site for my snapcast project so far, but I seem to be stuck when it comes to managing the latency between bluetooth (both snapclient and pulseaudio are running as user services) and soundcard output from my pi (through a dac hat to my living room audio receiver).
Any suggestions?
Snapcast lets you adjust the latency for each client individually, so it should be pretty simple to adjust. You don’t actually specify what the problem is, so I’m not sure how to help.
Nevermind my recent question/comment… snapweb lets you set the latency for each speaker, and my setup seems to like an 180ms advance. Super excited to have finally put all of this together… and your site was the final key. Thanks!!!