Turn Any Speaker into a Multi-Room Wireless Receiver

Turn Any Speaker into a Multi-Room Wireless Receiver

multi-room audio speakers snapcast

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:

generic speakers are turned into multi-room wireless receivers with snapcast and Raspberry Pis
The Raspberry Pis provide the “wireless receiver” functionality to the speakers via snapcast to create a whole house sound system.

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, and 1780 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.

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.

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 clients
  • snapclient(s): playing audio to the speakers
  • pulseaudio: 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…

Build Guides

Looking for even more detail?

Drop your email in the form below and you'll receive links to the individual build-guides and projects on this site, as well as updates with the newest projects.

... but this site has no paywalls. If you do choose to sign up for this mailing list I promise I'll keep the content worth your time.

Written by
(zane) / Technically Wizardry
Join the discussion

3 comments
  • 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?

  • 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!!!

Menu