While having our bathroom and sauna renovated, I wanted to be able to listen to music in sauna. Getting a speaker and its cable in place was straightforward, but after that the setup became more complex. Some amplifier with bluetooth audio or Spotify streaming would have been simple, but for bluetooth you’d still need the music somewhere, and I don’t want a Spotify subscription.

And of course you’d want the same music to play inside and outside the sauna. So, I had an excuse to build multi-room audio with inexpensive components for two rooms.

Software

Raspberry Pis were an easy choice to build around, so everything has to work in Raspbian Linux.

Music Player Daemon (MPD) is used for playing music, it has a local playlist that can be changed with clients so that the device can keep playing music even if no controllers are connected to it. If you do have that Spotify subscription, Mopidy is a MPD compatible server with Spotify support through extensions.

Soundirok is a MPD client for iOS devices. It supports also loading cover images with HTTP, so I have nginx set up for that purpose also. On macOS, I use ncmpcpp.

Multi-room audio is achieved with Snapcast. Unfortunately it doesn’t have an iOS client software available, so controlling different rooms’ volumes separately is not easily possible at the moment.

ALSA is used for controlling the sound cards. It’s not that simple to configure, but then again, it’s quite flexible when you do get the configuration in place.

For sorting out the music library, I use beets, but explaining that pipeline would be a topic for another blog post. Here I assume that the music just appears to the correct place.

Hardware

I have devices in two rooms: living room and sauna. rpi-olohuone in living room is the server device rpi-kph in sauna is a client device.

The server setup has Raspberry Pi model 1 B, but the actual model doesn’t really matter as everything could also be done with a Pi Zero also. Music is played with NuForce μDAC-2 USB sound card (aka DAC). Files are stored in a Seagate HDD.

μDAC and HDD are powered through USB, and at least the HDD requires more power than what Raspberry is able to give, so I have a powered TP-Link USB hub in the middle.

Sauna client is a Raspberry Pi Zero with a RedBear IoT pHAT for WiFi connectivity and an external antenna for added range. The case was not optimal, but does its job.

Adding pHAT required soldering both on the Pi Zero and on the pHAT itself. There is also a model Pi Zero W with WiFi included, and with that you don’t need the pHAT at all.

Selecting the sound card required a bit more thought as I had to have a DAC and amplifier both. After some googling, I ended up with Topping VX1, an USB-DAC and amplifier in the same device, which should also work in Linux!

Configuration

Configuration is done with Ansible and is available in my raspberry-ansible repo. Everything described here is included in two playbooks: bootstrap.yml and music.yml

Configuration: External HDD

bootstrap.yml playbook:

- include_role: name: external-hdd when: - exthdd is defined

host_vars/rpi-olohuone.yml :

exthdd: "/dev/sda1" exthdd_fstype: "vfat" exthdd_mountpoint: "/mnt/piikiekko"

roles/external-hdd/tasks/main.yml :

- name: mount device mount: src: "{{ exthdd }}" path: "{{ exthdd_mountpoint }}" fstype: "{{ exthdd_fstype }}" state: mounted opts: "umask=000" # allow writes from all users

Setting up the external HDD is quite simple with Ansible, you need only the mount module.

You can find out which device the external hdd is with lsblk and blkid (here /dev/sda1 ):

pi@rpi-olohuone:~ $ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT sda 8:0 0 931.5G 0 disk `-sda1 8:1 0 931.5G 0 part /mnt/piikiekko mmcblk0 179:0 0 14.4G 0 disk |-mmcblk0p1 179:1 0 41.5M 0 part /boot `-mmcblk0p2 179:2 0 14.4G 0 part / pi@rpi-olohuone:~ $ blkid /dev/mmcblk0p1: LABEL="boot" UUID="CDD4-B453" TYPE="vfat" PARTUUID="dba1b453-01" /dev/mmcblk0p2: LABEL="rootfs" UUID="72bfc10d-73ec-4d9e-a54a-1cc507ee7ed2" TYPE="ext4" PARTUUID="dba1b453-02" /dev/sda1: LABEL="PIIKIEKKO" UUID="8001-1AF6" TYPE="vfat"

See also External storage configuration on official Raspberry Pi documentation.

Configuration: RedBear IoT pHAT

bootstrap.yml playbook:

- include_role: name: wifi when: - enable_wifi is defined and enable_wifi == True

host_vars/rpi-kph.yml :

enable_wifi: True

group_vars/pi/vault.yml (file with secret variables, this can be edited with ansible-vault edit and the file in my github repo cannot be used, you’ll have to overwrite it with your own):

wifi_network: <network name> wifi_password: <password>

roles/external-hdd/tasks/main.yml :

- name: Add wifi configuration blockinfile: dest: "/etc/wpa_supplicant/wpa_supplicant.conf" block: | network={ ssid="{{ wifi_network }}" psk="{{ wifi_password }}" key_mgmt=WPA-PSK } when: ansible_wlan0 notify: restart machine

Setting up the RedBear IoT pHAT is also really simple, Linux kernel notices the device itself and you just have to setup the correct WiFi network parameters.

See also RedBear’s installation instructions.

Configuration: Sound cards / ALSA

ALSA configuration was probably the hardest part to get working, especially with the Topping VX1 sound card.

music.yml playbook, relevant parts:

- hosts: music-server roles: - alsa - hosts: music-client roles: - alsa

host_vars/rpi-olohuone.yml :

asound_conf: | pcm.!default { type hw card 1 } ctl.!default { type hw card 1 }

Living room ALSA configuration is fairly simple, set the card number 1 (NuForce μDAC) as the default. Card numbers can be printed with aplay -l :

pi@rpi-olohuone:~ $ sudo aplay -l **** List of PLAYBACK Hardware Devices **** card 0: ALSA [bcm2835 ALSA], device 0: bcm2835 ALSA [bcm2835 ALSA] Subdevices: 8/8 Subdevice #0: subdevice #0 Subdevice #1: subdevice #1 Subdevice #2: subdevice #2 Subdevice #3: subdevice #3 Subdevice #4: subdevice #4 Subdevice #5: subdevice #5 Subdevice #6: subdevice #6 Subdevice #7: subdevice #7 card 0: ALSA [bcm2835 ALSA], device 1: bcm2835 ALSA [bcm2835 IEC958/HDMI] Subdevices: 1/1 Subdevice #0: subdevice #0 card 1: N2 [NuForce µDAC 2], device 0: USB Audio [USB Audio] Subdevices: 1/1 Subdevice #0: subdevice #0 card 1: N2 [NuForce µDAC 2], device 1: USB Audio [USB Audio #1] Subdevices: 1/1 Subdevice #0: subdevice #0

host_vars/rpi-olohuone.yml :

asound_conf: | pcm.!default makemono pcm.makemono { type route slave.pcm "sysdefault:VX1" ttable { 0.0 1 # in-channel 0, out-channel 0, 100% volume 1.0 1 # in-channel 1, out-channel 0, 100% volume } }

There is only one speaker in sauna, so I’d want both the channels to be mixed into mono. Luckily there was a snippet for this. sysdefault:VX1 comes from aplay -L output, chosen with trial and error:

pi@rpi-kph:~ $ sudo aplay -L null Discard all samples (playback) or generate zero samples (capture) makemono sysdefault:CARD=ALSA bcm2835 ALSA, bcm2835 ALSA Default Audio Device ... sysdefault:CARD=VX1 VX1, USB Audio Default Audio Device ...

roles/alsa/tasks/main.yml :

- name: install alsa-utils apt: name: "{{ item }}" with_items: - "alsa-utils" - name: add snd_bcm2835 kernel module modprobe: name: "snd_bcm2835" - name: add snd_bcm2835 kernel module to be loaded on boot lineinfile: path: "/etc/modules" line: "snd_bcm2835" - name: setup alsa default sound card copy: content: "{{ asound_conf }}" # device-dependent dest: "/etc/asound.conf" notify: - reboot - wait for reboot

The task configures also the Raspberry Pi onboard sound card ( snd_bcm2835 ) into use, even though neither of these current devices use it. The installed package alsa-utils can be used to test the configuration:

pi@rpi-kph:~ $ sudo speaker-test -c 2 speaker-test 1.1.3 Playback device is default Stream parameters are 48000Hz, S16_LE, 2 channels Using 16 octaves of pink noise Rate set to 48000Hz (requested 48000Hz) Buffer size range from 1024 to 8192 Period size range from 511 to 513 Using max buffer size 8192 Periods = 4 was set period_size = 512 was set buffer_size = 8192 0 - Front Left 1 - Front Left

should output noise on both channels in turns (and from the same speaker in sauna).

See also Alsa Opensrc Org: .asoundrc for troubleshooting help.

Configuration: Snapcast

Snapcast has to be configured so that music player, MPD sends audio to Snapcast server, which will forward the audio to all connected clients. In order to get the audio synchronized, also the device with MPD has to play music through Snapcast client.

music.yml playbook, relevant parts:

- hosts: music-server roles: - snapcast-server - snapcast-client - hosts: music-client roles: - snapcast-client

roles/snapcast-server/tasks/main.yml

- name: install snapserver apt: deb: "https://github.com/badaix/snapcast/releases/download/v0.12.0/snapserver_0.12.0_armhf.deb" - name: open ports in firewall ufw: rule: "allow" port: "{{ item }}" with_items: - 1704 - 1705

Snapcast server is not available from Debian or Raspbian Aptitude repos, but luckily an Aptitude package is available in Github. The built package is different for different processor families, _armhf is the correct for Raspberry Pi, others are listed in the releases page.

Snapcast server requires ports 1704 and 1705 to be open, but otherwise the default configuration doesn’t need any adjustments.

roles/snapcast-client/tasks/main.yml

- name: install snapclient apt: deb: "https://github.com/badaix/snapcast/releases/download/v0.12.0/snapclient_0.12.0_armhf.deb" - name: configure snapcast server address lineinfile: path: "/etc/default/snapclient" regexp: "^SNAPCLIENT_OPTS" line: "SNAPCLIENT_OPTS=\"{{ snapclient_opts }}\"" notify: restart snapcast client

host_vars/rpi-olohuone.yml

snapcast_server: "192.168.1.249" snapclient_opts: "--host {{ snapcast_server }}"

host_vars/rpi-kph.yml

snapcast_server: "192.168.1.249" snapclient_opts: "--host {{ snapcast_server }} --soundcard makemono"

Snapcast client is installed from the same place as server. It doesn’t require that much configuration either, only the server IP address on both clients, and additionally the custom sound card makemono on rpi-kph (it’s supposed to be the default, don’t know why it didn’t work with snapclient without specifying it like this).

Troubleshooting Snapcast server can be started with systemctl , working output is something like this:

pi@rpi-olohuone:~ $ sudo systemctl -l status snapserver.service ● snapserver.service - Snapcast server Loaded: loaded (/lib/systemd/system/snapserver.service; enabled; vendor preset: enabled) Active: active (running) since Sun 2018-01-21 00:30:27 EET; 1 weeks 3 days ago Process: 464 ExecStart=/usr/bin/snapserver -d $USER_OPTS $SNAPSERVER_OPTS (code=exited, status=0/SUCCESS) Main PID: 472 (snapserver) CGroup: /system.slice/snapserver.service └─472 /usr/bin/snapserver -d --user snapserver:snapserver Jan 21 00:30:26 rpi-olohuone systemd[1]: Starting Snapcast server... Jan 21 00:30:27 rpi-olohuone snapserver[464]: Settings file: "/var/lib/snapserver/server.json" Jan 21 00:30:27 rpi-olohuone snapserver[464]: 2018-01-21 00-30-27 [Notice] Settings file: "/var/lib/snapserver/server.json" Jan 21 00:30:27 rpi-olohuone snapserver[472]: daemon started Jan 21 00:30:27 rpi-olohuone systemd[1]: Started Snapcast server. Jan 21 01:26:56 rpi-olohuone snapserver[472]: StreamServer::NewConnection: ::ffff:192.168.1.249 Jan 21 01:27:34 rpi-olohuone snapserver[472]: StreamServer::NewConnection: ::ffff:192.168.1.3

And Snapcast client the same way:

pi@rpi-kph:~ $ sudo systemctl status -l snapclient.service ● snapclient.service - Snapcast client Loaded: loaded (/lib/systemd/system/snapclient.service; enabled; vendor preset: enabled) Active: active (running) since Sun 2018-01-21 01:27:34 EET; 1 weeks 3 days ago Process: 1155 ExecStart=/usr/bin/snapclient -d $USER_OPTS $SNAPCLIENT_OPTS (code=exited, status=0/SUCCESS) Main PID: 1156 (snapclient) CGroup: /system.slice/snapclient.service └─1156 /usr/bin/snapclient -d --user snapclient:audio --host 192.168.1.249 --soundcard makemono Jan 21 01:27:34 rpi-kph systemd[1]: Starting Snapcast client... Jan 21 01:27:34 rpi-kph snapclient[1156]: daemon started Jan 21 01:27:34 rpi-kph systemd[1]: Started Snapcast client. Jan 21 01:27:34 rpi-kph snapclient[1156]: Connected to 192.168.1.249

Configuration: MPD

music.yml playbook, relevant parts:

- hosts: music-server roles: - mpd-server

roles/mpd-master/tasks/main.yml :

- name: install mpd, nginx, mpc apt: name: "{{ item }}" with_items: - "mpd" - "nginx" - "mpc" - name: copy mpd configuration template: src: "mpd.conf.j2" dest: "/etc/mpd.conf" notify: restart mpd - name: copy nginx cover art configuration template: src: "mpd-cover-art.conf.j2" dest: "/etc/nginx/sites-available/mpd-cover-art.conf" notify: restart nginx - name: symlink nginx cover art configuration file: src: "/etc/nginx/sites-available/mpd-cover-art.conf" dest: "/etc/nginx/sites-enabled/mpd-cover-art.conf" state: "link" notify: restart nginx - name: open ports in firewall ufw: rule: "allow" port: "{{ item }}" with_items: - "http" - 6600

MPD role installs and configures the MPD server (of course), and also nginx HTTP server for serving albums’ cover art images. MPD controls use port 6600.

mpc is a command-line client for MPD. It is not used in these Ansible scripts, but comes in very handy when you add music to the library and the MPD database has to be updated.

roles/mpd-master/templates/mpd.conf.j2 :

music_directory "{{ music_dir }}" playlist_directory "/var/lib/mpd/playlists" db_file "/var/lib/mpd/tag_cache" log_file "/var/log/mpd/mpd.log" pid_file "/run/mpd/pid" state_file "/var/lib/mpd/state" sticker_file "/var/lib/mpd/sticker.sql" user "mpd" bind_to_address "0.0.0.0" filesystem_charset "UTF-8" id3v1_encoding "UTF-8" input { plugin "curl" } #audio_output { # type "alsa" # name "My ALSA Device" #} audio_output { type "fifo" name "snapcast" path "/tmp/snapfifo" format "48000:16:2" mixer_type "software" }

MPD configuration is fairly default. audio_output is the interesting part: MPD plays music to /tmp/snapfifo stream, from where the Snapcast server reads it.

host_vars/rpi-olohuone.yml

mpd_server: True music_dir: "{{ exthdd_mountpoint }}/musiikkia"

Variables are also straightforward, define this device as MPD server, and specify where the music is located.

MPD database can be updated with mpc :

pi@rpi-olohuone:~ $ mpc update Updating DB (#1) ... volume: 82% repeat: off random: off single: off consume: off

and queried, queued and played:

pi@rpi-olohuone:~ $ mpc search album highway Bob Dylan/Highway 61 Revisited/01 Like a Rolling Stone.flac Bob Dylan/Highway 61 Revisited/02 Tombstone Blues.flac ... pi@rpi-olohuone:~ $ mpc add "Bob Dylan/Highway 61 Revisited/01 Like a Rolling Stone.flac" pi@rpi-olohuone:~ $ mpc play Bob Dylan - Like a Rolling Stone [playing] #1/1 0:00/6:13 (0%) volume: 82% repeat: off random: off single: off consume: off

Although you should definitely use some other client such as Soundirok for iOS or ncmpcpp for macOS/Linux.

Troubleshooting can be started with systemctl again:

pi@rpi-olohuone:~ $ sudo systemctl status -l mpd.service ● mpd.service - Music Player Daemon Loaded: loaded (/lib/systemd/system/mpd.service; enabled; vendor preset: enabled) Active: active (running) since Sun 2018-01-21 00:30:27 EET; 1 weeks 3 days ago Docs: man:mpd(1) man:mpd.conf(5) file:///usr/share/doc/mpd/user-manual.html Main PID: 469 (mpd) CGroup: /system.slice/mpd.service └─469 /usr/bin/mpd --no-daemon Jan 21 00:30:27 rpi-olohuone systemd[1]: Started Music Player Daemon.

Configuration: Soundirok

For Soundirok, MPD server has to be added under Settings → Devices:

If I remember correctly, Soundirok should start synchronizing the MPD music library to the client. It can be done manually by clicking the top bar device name (“Olohuone” in my case) and selecting Refresh Soundirok database.

Closing remarks

I was planning that the sauna devices could be turned on and off with the light switch, and there are power sockets to enable this. However, even though Topping VX1 features include Auto turn on / turn off synchronously with your PC (Only in USB mode), I haven’t figured out how to do this. Might be that this only works in Windows.

When the devices are powered on (or get electricity), I still have to press a button in VX1 to wake it up. It would be easier if this wasn’t necessary as the device isn’t in a very easily accessible place. As a workaround, I’ve had it turned on most of the time.

Other than that, the setup works really well.

You can leave questions and comments to Reddit.

More photos

Sauna devices are in the bathroom ceiling.

Speaker is under the sauna benches. The speaker is a weather-proof Bower & Wilkins AM-1 and it sounds really good.