AirPlay support for Logitech Squeezebox devices

On Friday 8th April, ShairPort was released. Containing the private key from a reverse-engineered Apple AirPort Express, this allows unlicensed/homebrew devices to act as AirPlay target speakers – e.g. allows iTunes, iPods, iPads, and iPhones to use them as an output device.

Immediately, the obvious thought is to add AirPlay support to Logitech/Slim Devices’ Squeezebox Server software so that the excellent Squeezebox devices can be used as remote speakers.

(As an aside, I’ve had my 3rd generation Squeezeboxsince they were introduced in 2005, and it is without the highest quality and most used gadget I have, still going strong and as useful as ever more than five years later!)

After a few false-starts trying to configure ALSA to record the digital output of the host’s soundcard, the latest release of ShairPort provides a perfect solution to lossless audio reproduction, without even needing a soundcard.

Given that WaveInput is designed to re-record a soundcard’s output, this was my first vector of attack. After several days of reading asound.conf definitions and a great deal of head-scratching, the best I managed was working but massively distorted and noisy AirPlay. It worked, but the sound codec in my server (a Realtek ALC887) only supports Analogue recording from the Headphone output to the Microphone input. I suspect that the fact that these physical inputs were disconnected gave rise to the static and noise. I may, with time, have been able to fix this with a combination of plugs and plugins in ALSA, but it was just too much of a hack.

So I had a re-think, and moved on to:

Setup:

I have a storage-server which contains my digital music files, and also runs the Squeezebox Server software. This machine runs Gentoo Linux with the gentoo-specific build of this software, which splits it up into different locations on the filesystem rather than keeping everything in one directory – that should not affect these instructions.

The Squeezebox hardware is in a different room, connected only by being on a Wifi network which is bridged to the wired network the storage server runs on.

Pre-requisites:

To follow this guide, you will need:

  • A media server (which needn’t be powerful: this one is a dual-core Intel Atom machine);
  • … with at least perl-5.10.0;
  • … and the latest version of Logitech’s Squeezebox Server software;
  • … along with avahi to provide Rendezvous/Bonjour auto-discovery on Linux.

ShairPort itself requires IPv6 to be supported and enabled (/sbin/ifconfig | grep -B2 inet6), and requires perl’s IO::Socket::INET6 to be installed.

Note that the Squeezebox Server plugin used here is also available on Windows, but I don’t know how to interact with this OS’ pipe implementation.

Mac OS users are actually in a much better position: pre-0.5 ShairPort implementations would have needed a utility such as Soundflower in order to record audio output – this the new approach neatly sidesteps this issue. As OS X is fully POSIX-compliant, named pipes work just as below.

Method:

ShairPort’s hairtunes code now supports audio output to a named pipe, so all we need do is to make use of this option, and then read the data from this pipe back into Squeezebox Server (via the WaveInput plugin) in order to have (mostly) lossless AirPlay/AirTunes audio via Squeezebox. ecasound will block on reading from the pipe if no writer is attached, and hairtunes will block on write without a reader. iTunes will continue to play regardless, and there may be instances where stale data is read from the pipe when a reader re-connects. On Linux, pipes will cache up to 64k of data, and since writing is done in real-time with no seek capability drop-outs are uncommon but possible if the network is congested. It is possible to cause ecasound to buffer more data, at the expense of lag when starting playback or changing tracks. With the default settings, there is a delay of about a second due to buffering.

Installation:

For a Gentoo Squeezebox Server installation (“emerge -v squeezeboxserver“), a stack of perl modules will have to have been required. In addition to this, ShairPort also needs:

IO::Socket::SSL
IO::Socket::INET6
Crypt::OpenSSL::RSA

… to be installed, so “emerge -v IO-Socket-SSL IO-Socket-INET6 Crypt-OpenSSL-RSA” and resolve any keywords/dependency conflicts which may occur. Also install avahi and ecasound whilst at it, this latter being the suggested tool to transcode the sound stream from AirPlay/AirTunes.

Ensure that Squeezebox Server works and can contact the Squeezebox hardware, and then go to Settings -> Plugins and look for a “WaveInput” item. If not present, then add “http://bpaplugins.googlecode.com/svn/trunk/repo.xml” in the text-box at the bottom of the page, Apply, and then look for WaveInput in the new “bpa’s Squeezecenter Plugins” section. Tick this, and a “WaveInput” directory will be created in the Squeezebox Server “Plugins” directory.

(WIth the Gentoo-specific installation, this will be in /var/lib/squeezeboxserver/cache/InstalledPlugins/Plugins/, but since we’ll be customising the supplied files I suggest moving the WaveInput directory to /var/lib/squeezeboxserver/Plugins/)

Within the WaveInput directory, there will be a selection of custom-convert.conf files – backup any existing custom-convert.conf and rename custom-convert.conf.ecasound to have this name. Edit the contents to read:

1
2
3
4
5
6
7
8
9
10
11
12
#
# wavin
#
wavin wav * *
# IFR
[ecasound] -q -z:db -b:4096 -f:16,2,44100 -i /var/lib/squeezeboxserver/airplay-fifo.raw -o stdout
wavin mp3 * *
# IFRB:{BITRATE=-B %B}D:{RESAMPLE=--resample %D}
[ecasound] -q -z:db -b:4096 -f:16,2,44100 -i /var/lib/squeezeboxserver/airplay-fifo.raw -o stdout  | [lame] --silent -r -x -q $QUALITY$ $RESAMPLE$ -v $BITRATE$ - -
wavin flc * *
# IFRD:{RESAMPLE=-r %d}
[ecasound] -q -z:db -b:4096 -f:16,2,44100 -i /var/lib/squeezeboxserver/airplay-fifo.raw -o stdout  | [flac] -cs --totally-silent --endian=little --channels=2 --sign=signed --bps=16 --sample-rate=44100 --compression-level-0 -

Noting that /var/lib/squeezeboxserver/airplay-fifo.raw is the location I’ve chosen to store the reference to the named pipe in the filesystem – no data is ever written to this file. The name must end in “.raw” in order for ecasound to be able to recognise the type of data coming from it.

In Squeezebox Server’s web user-interface, create a new Favourite named “AirPlay” with URL “wavin:default” (although the text after the colon is not used, and is a carry-over from the previous ALSA-based attempts at getting AirPlay to work).

Download ShairPort (“git clone https://github.com/albertz/shairport.git“) and edit the Makefile if necessary – I set custom CFLAGS and LDFLAGS to match the system:

$ diff Makefile Makefile.local
2,3c2,3
< CFLAGS:=-O2 -Wall
< LDFLAGS:=-lm -lpthread
---
> CFLAGS:=-march=atom -Os -pipe -Wall

> LDFLAGS:=-lm -lpthread -Wl,-O1 -Wl,--as-needed

… and run ‘make -f Makefile.local‘ – you will need a working C compiler installed (or Xcode on OS X – Windows users need Cygwin).

Update: ShairPort 0.5 is now updated with my additional fixes and Squeezebox control code so this next part no longer applies! ;)

If using ShairPort 0.5, edit the shairport.pl file, and make the following changes to fix code-correctness:

$ diff shairport.pl /usr/local/bin/shairport.pl
1c1,15
< #!/usr/bin/env perl
---
> #!/bin/sh

> if test -n "`perl -V | grep "5\.0"`"
> then
>    echo -n "FATAL: You appear to have perl "
>    for WORD in `perl -v | grep "^This is "`
>    do
>        echo $WORD
>    done | grep "5" | xargs echo -n
>    echo ", but at least version 5.10.0 is required."
>    exit 1
> fi
> exec perl -wx $0 "$@"
>    if 0;
> #!perl -w
> #line 16
27a42,43
> use strict;
>
61a78
> my $help;
64,68c81,86
< say "Can't find the 'hairtunes' decoder binary, you need to build this before using Shairport.";
<     say "Trying to build it for you anyway...";
<     system("cd ${FindBin::Bin}; make || gmake");
<     die("Nope, didn't work out. Read the INSTALL instructions!") unless -x $hairtunes_cli;
<     say "Phew! Worked out okay, by the looks of it.";
---
>     die "Can't find the 'hairtunes' decoder binary, you need to build this before using Shairport.";

>     #say "Can't find the 'hairtunes' decoder binary, you need to build this before using Shairport.";
>     #say "Trying to build it for you anyway...";
>     #system("cd ${FindBin::Bin}; make || gmake");
>     #die("Nope, didn't work out. Read the INSTALL instructions!") unless -x $hairtunes_cli;
>     #say "Phew! Worked out okay, by the looks of it.";
137c155
< exec 'avahi-publish-service',
---
>     { exec 'avahi-publish-service',

141,142c159,160
< "tp=UDP", "sm=false", "sv=false", "ek=1", "et=0,1", "cn=0,1", "ch=2", "ss=16", "sr=44100", "pw=false", "vn=3", "txtvers=1";
<     exec 'dns-sd', '-R',
---
>         "tp=UDP", "sm=false", "sv=false", "ek=1", "et=0,1", "cn=0,1", "ch=2", "ss=16", "sr=44100", "pw=false", "vn=3", "txtvers=1"; };

>     { exec 'dns-sd', '-R',
147c165
< "tp=UDP", "sm=false", "sv=false", "ek=1", "et=0,1", "cn=0,1", "ch=2", "ss=16", "sr=44100", "pw=false", "vn=3", "txtvers=1";
---
>         "tp=UDP", "sm=false", "sv=false", "ek=1", "et=0,1", "cn=0,1", "ch=2", "ss=16", "sr=44100", "pw=false", "vn=3", "txtvers=1"; };

216c234
< foreach $fh (@waiting) {
---
>     foreach my $fh (@waiting) {

(Note to self: Update diff syntax for GeSHi to support unified diffs… I’m afraid the spacing appears to be out above on the first line of each original block above – I’ll try to fix this layout problem)

… and copy hairtunes and shairport.pl to the desired final location – I used /usr/local/bin/.

Finally, we just need a script to start ShairPort with the correct options. This is again targetted at Gentoo, but any Linux distribution will be similar. ShairPort 0.5 includes a plist for OS X as well as installation instructions. I guess Windows users could create a service… it’s not really my area (… but if you have perl working and a C compiler, I’m guessing you’re way ahead of me at this point ;)

/etc/conf.d/airplay:

1
2
3
4
5
6
7
8
9
10
11
12
# Settings for shairport/Apple AirPlay daemon...
AIRPLAY_NAME="Squeezebox Airplay"
AIRPLAY_PASSWD=""

# Send output to a pipe/FIFO
AIRPLAY_PIPE="/var/lib/squeezeboxserver/airplay-fifo.raw"

# Audio output options
#AIRPLAY_AUDIO_DRIVER="alsa"
#AIRPLAY_AUDIO_DEVICE="hw:0,0"

AIRPLAY_USE_SQUEEZEBOX="1"

/etc/init.d/airplay (Updated for Squeezebox support):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#!/sbin/runscript

AIRPLAY=/usr/local/bin/shairport.pl
uid=nobody
gid=nogroup
pid=/var/run/airplay.pid

depend() {
    need avahi-daemon
    use squeezeboxserver
}

start() {
    local OPTS=""

    if ! [[ -x "$AIRPLAY" ]]; then
        eerror "Cannot locate AirPlay daemon '$AIRPLAY'"
        return 1
    fi
    if [[ -z "$AIRPLAY_NAME" ]]; then
        eerror "Access Point name not set"
        return 1
    fi
    if [[ -n "$AIRPLAY_USE_SQUEEZEBOX" ]]; then
        if [[ -r /etc/conf.d/squeezeboxserver ]]; then
            SBS_OPTS="$( grep "^SBS_OPTS" /etc/conf.d/squeezeboxserver | sed -r '/^SBS_OPTS/s:^.*\s(--cliport\s+[0-9]+).*$:\1:' )"
        fi
        OPTS="$OPTS --squeezebox"
    fi
    if [[ -n "$AIRPLAY_PIPE" ]]; then
        if [[ -p "$AIRPLAY_PIPE" ]]; then
            chmod 0660 "$AIRPLAY_PIPE" && \
            chown $uid:audio "$AIRPLAY_PIPE" && \
            einfo "Using existing named pipe \"$AIRPLAY_PIPE\" for output" || \
            { eend $? "Unable to set correct metadata on named pipe \"$AIRPLAY_PIPE\"" ; return 1 ; }
        else
            mkfifo -m 0660 "$AIRPLAY_PIPE" && \
            chown $uid:audio "$AIRPLAY_PIPE" && \
            einfo "Named pipe \"$AIRPLAY_PIPE\" created" || \
            { eend $? "Failed to create named pipe \"$AIRPLAY_PIPE\"" ; return 1 ; }
        fi
        OPTS="$OPTS --pipe=\"$AIRPLAY_PIPE\""
    else
        if [[ -n "$AIRPLAY_AUDIO_DRIVER" ]]; then
            OPTS="$OPTS --ao_driver=\"$AIRPLAY_AUDIO_DRIVER\""
            einfo "Using audio driver \"$AIRPLAY_AUDIO_DRIVER\""
        fi
        if [[ -n "$AIRPLAY_AUDIO_DEVICE" ]]; then
            OPTS="$OPTS --ao_devicename=\"$AIRPLAY_AUDIO_DEVICE\""
            einfo "Using audio device \"$AIRPLAY_AUDIO_DEVICE\""
        fi
    fi
    if [[ -n "$AIRPLAY_PASSWD" ]]; then
        ebegin "Starting password-protected AirPlay daemon for Access Point '$AIRPLAY_NAME'"
        OPTS="$OPTS -p \"$AIRPLAY_PASSWD\""
    else
        ebegin "Starting AirPlay daemon for Access Point '$AIRPLAY_NAME'"
    fi
    if ! start-stop-daemon --start --exec "$AIRPLAY" --chuid $uid --background --make-pidfile --pidfile "$pid" -- -a "$AIRPLAY_NAME" $OPTS; then
        export pid AIRPLAY AIRPLAY_NAME OPTS
        touch "$pid"
        chown $uid:$gid "$pid"
        #su - $uid -m /bin/sh -c "echo \"\$$\" >\"$pid\" ; exec \"$AIRPLAY\" -a \"$AIRPLAY_NAME\" $OPTS /dev/null 2>&1" &
        su - $uid -m /bin/sh -c "\"$AIRPLAY\" -a \"$AIRPLAY_NAME\" -d -w \"$pid\" $OPTS $SBS_OPTS"
    fi
    eend $? "Failed to start \"$AIRPLAY\""
}

stop() {
    ebegin "Stopping AirPlay daemon"
    start-stop-daemon --stop --exec "$AIRPLAY" --pidfile "$pid" --retry
    eend $? "Failed to stop \"$AIRPLAY\"$( test -e "$pid" && echo " (PID " && cat "$pid" && echo ")" )"
}

status() {
    if [[ -e "
$pid" ]]; then
        einfo "
AirPlay daemon $( "$AIRPLAY" --help | head -n 1 | cut -d" " -f 3 ) running as PID $( cat "$pid" )"
    fi
}

… customise these files with any installation-specific paths, and things are ready to go!

Once the airplay script is started, iTunes and any iOS devices should see a new AirPlay target with the specified name (“Squeezebox Airplay”) and can play to this immediately. One the Squeezebox main menu, choose Favourites and press play, and after a second’s delay (due to buffering) you should get crystal-clear AirPlay audio!

Sound data is sent uncompressed from hairtunes, but will be converted by Squeezebox Server to the most efficient format your hardware supports – but network congestion can cause drop-outs which will cause playback pauses (bear in mind that you’ve likely got one audio stream from the source device to the AirPlay server, and another from the AirPlay server to the Squeezebox – so this isn’t something for a loaded 802.11b Wifi network…).

Given that the kernel will only cache a maximum of 64k of data for a named pipe, there is no risk if Squeezebox Server stops reading from the pipe or crashes. iTunes at least seems happy to send data even if the pipe is blocked, and playback will start (with a potential 64k glitch and then re-sync delay) as soon as the pipe has a reader attached. ecasound will happily sit on the pipe waiting for data to appear, and Squeezebox Server seems happy to handle this (showing the AirPlay Favourite as playing and with the playback timer increasing as expected). I’ve not yet had chance to test when happens if multiple clients attempt to connect – I assume that the AirPlay protocol handles this itself before the audio layer is involved.

So that’s it! AirPlay on Squeezebox – life’s good :)

(Bonus points: WTF isn’t start-stop-daemon working in the above script, necessitating the work-around implemented below? Free Promo code for one of my commercial iOS apps to anyone who can enlighten me in the comments below…)