One of the things I’ve been experimenting with when building out my smart home is multi-room audio (the ability to play perfectly synchronized audio from multiple devices). Thankfully there is already a mature solution for routing and synchronizing audio: Snapcast. The only tricky part is getting audio from various sources and feeding it into the Snapcast server. In this article, we will take a look at Bluetooth audio via A2DP.
The Goal
This article is going to walk you through setting up a headless Linux device that hosts the Snapcast server. We are going to assume a Debian-based distribution, which means these instructions should also work well on a Raspberry Pi running Raspberry Pi OS. This article may still be useful if you are using another distribution, though you will have to adjust the package commands, at the very least.
You will also need a connected Bluetooth receiver if your device doesn’t include one.
Setting Up the Host
Begin by logging into the host and running the following command:
sudo loginctl enable-linger $USER
This enables applications to run under your account even when you aren’t logged in. Without linger enabled, systemd user services (such as PipeWire) wouldn’t start until you logged in and would be terminated when you logged out.
Speaking of PipeWire, let’s install it and a few other necessary packages:
sudo apt install bluez libspa-0.2-bluetooth pipewire wireplumber snapserver
Setting Up Pipewire
The first step is to enable PipeWire and WirePlumber (session manager for PipeWire):
systemctl --user enable pipewire
systemctl --user enable wireplumber
Snapserver cannot read directly from PipeWire, but it can read from a FIFO pipe. Therefore we need to create a sink that writes its data to a pipe. We can do this by creating a configuration file:
mkdir -p ~/.config/pipewire/pipewire.conf.d
cat > ~/.config/pipewire/pipewire.conf.d/snapserver.conf << 'EOF'
context.modules = [
{
name = libpipewire-module-pipe-tunnel
args = {
tunnel.mode = sink
pipe.filename = "/tmp/snapfifo"
node.name = "Snapserver"
node.description = "Snapserver Sink"
media.class = Audio/Sink
audio.position = [ FL FR ]
audio.format = S16LE
audio.rate = 48000
audio.channels = 2
}
}
]
EOF
Start PipeWire with:
systemctl --user start pipewire
Setting Up WirePlumber
We need to work around a little quirk in WirePlumber. The login service (logind) assigns seat states to sessions. Remote sessions are marked as “online” but the bluez.lua script included with WirePlumber looks for “active” desktop sessions before monitoring for Bluetooth devices. So we need to disable this behavior and the best way to do that is to create a copy of the default wireplumber.conf:
mkdir -p ~/.config/wireplumber
cp /usr/share/wireplumber/wireplumber.conf ~/.config/wireplumber
Now open the file and scroll down to the “main” section:
wireplumber.profiles = {
main = {
...
Add the following line to the “main” section:
wireplumber.profiles = {
main = {
monitor.bluez.seat-monitoring = disabled
...
Save and close the file. WirePlumber will now use your modified user copy of wireplumber.conf instead of the system-wide one.
We also need to tell WirePlumber to use our PipeWire sink from above whenever a Bluetooth device is connected. Fortunately, this is pretty easy to do:
mkdir -p ~/.config/wireplumber/wireplumber.conf.d
cat > ~/.config/wireplumber/wireplumber.conf.d/snapserver.conf << 'EOF'
monitor.bluez.rules = [
{
matches = [
{
"node.name" = "~bluez_input.*"
}
]
actions = {
update-props = {
"node.target" = "Snapserver"
}
}
}
]
EOF
When a supported Bluetooth audio device connects, a node is created. The rule above will match it and set its target property to the “Snapserver” sink, instead of the default audio sink (which is usually the primary sound card).
Start WirePlumber with:
systemctl --user start wireplumber
Setting Up Snapcast and Bluetooth
We’re almost there!
Open the /etc/snapserver.conf file and scroll down to the [stream] section and find the source directive. Change it to:
source = pipe:///tmp/snapfifo?name=PipeWire&codec=pcm&sampleformat=48000:16:2
Snapserver is probably already running, so you will need to restart it to pick up the changes:
systemctl --user restart snapserver
Lastly we can set the name of our Bluetooth device:
bluetoothctl system-alias "Snapcast Audio"
Testing It Out
You can now pair a device to “Snapcast Audio” and it should be streamed to the Snapcast server. Other Snapcast clients on the local network should automatically find it and begin playing the audio. If your Bluetooth device is having trouble connecting to the host, try trusting and initiating the connection from the host:
bluetoothctl trust [MAC]
bluetoothctl connect [MAC]