Skip to content

Commit

Permalink
multiroom: update snapcast to v0.22 and various improvements
Browse files Browse the repository at this point in the history
- Update snapcast to v0.22.0-r0
- Replace FIFO stream source with ALSA which significantly reduces CPU and I/O usage (fixes #294)
- Add SOUND_INPUT_LATENCY and SOUND_OUTPUT_LATENCY env vars (also helps with #294)
- Reduce multiroom-server image with multi stage builds for a slimmer image
- Reduce verbose level for both multiroom services

Connects-to: #294
Change-type: minor
Signed-off-by: Tomás Migone <tomas@balena.io>
  • Loading branch information
tmigone committed Nov 27, 2020
1 parent e78a3ad commit d3312c2
Show file tree
Hide file tree
Showing 9 changed files with 56 additions and 22 deletions.
5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ Standalone mode is easy to understand. You just pipe ` balena-sound.input` to `b

### Multiroom
![](https://raw.githubusercontent.com/balenalabs/balena-sound/master/docs/images/arch-multiroom.png)
*Note that this image is currently outdated. FIFO file is no longer being used as of v3.4.0. Image update pending.*

Multiroom feature relies on `snapcast` to broadcast the audio to multiple devices. Snapcast has two binaries working alonside, server and client.

Snapcast server expects audio to be written into a FIFO file, so we create an additional sink (`snapcast` sink) that routes audio from `balena-sound.input` into said FIFO file. The server will then read the file and use TCP packets to broadcast audio to all clients that are connected to it, wether they run in the same device or others. Note that when writting into the FIFO file the audio is "exiting" the `audio` block and no longer under PulseAudio's control.
Snapcast server can receive audio from an ALSA stream, so we create an additional sink (`snapcast` sink) that routes audio from `balena-sound.input` and configure snapcast to grab the audio from the sink monitor. The server will then use TCP packets to broadcast audio to all clients that are connected to it, wether they run in the same device or others. Note that the audio is "exiting" the `audio` block and no longer under PulseAudio's control.

Snapcast client receives the audio from the server and sends it back into the `audio` block, in particular to `balena-sound.output` sink which will in turn send the audio to whatever output was selected by the user.

Expand All @@ -84,6 +85,7 @@ This setup allows us to decouple the multiroom feature from the `audio` block wh
As described above, plugins are the services generating the audio to be streamed/played. Plugins are responsible for sending the audio into the `audio` block, particularily into `balena-sound.input` sink. There are two alternatives for how this can be acomplished. A detailed explanation can be found [here](https://github.com/balenablocks/audio#usage), in our case:

**PulseAudio backend**

Most audio applications support using PulseAudio as an audio backend. This means the application was coded to allow sending audio directly to PulseAudio (and hence the `audio` block). This is usually configurable via a CLI option flag or configuration files. You should check your application's documentation and figure out if this is the case.

If the application supports PulseAudio backend, the only configuration you need is to specify where the PulseAudio server can be located. This can be done by setting the `PULSE_SERVER` environment variable, we recommend doing it in the `Dockerfile`:
Expand All @@ -93,6 +95,7 @@ ENV PULSE_SERVER=tcp:localhost:4317
```

**ALSA bridge**

If your application does not have built-in PulseAudio support, you can create a bridge to it by using ALSA. This can't be added in easily, so we wrote a little script that will do the work for you:

```
Expand Down
6 changes: 3 additions & 3 deletions core/audio/balena-sound.pa
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
# Create balena-sound sinks
load-module module-null-sink sink_name=balena-sound.input
load-module module-null-sink sink_name=balena-sound.output
load-module module-pipe-sink file=/var/cache/snapcast/snapfifo sink_name=snapcast format=s16le rate=44100
load-module module-null-sink sink_name=snapcast

# Route audio internally, loopback sinks depend on configuration. See start.sh for details:
# balena-sound.input: For multiroom it's routed to snapcast sink, for standalone directly wired to balena-sound.output
# balena-sound.output: Set to audio sink specified by audio block
load-module module-loopback source="balena-sound.input.monitor" %INPUT_SINK%
load-module module-loopback source="balena-sound.output.monitor" %OUTPUT_SINK%
load-module module-loopback latency_msec=%INPUT_LATENCY% source="balena-sound.input.monitor" %INPUT_SINK%
load-module module-loopback latency_msec=%OUTPUT_LATENCY% source="balena-sound.output.monitor" %OUTPUT_SINK%

# Route all plugin input to the default sink
set-default-sink balena-sound.input
14 changes: 14 additions & 0 deletions core/audio/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ set -e
CONFIG_TEMPLATE=/usr/src/balena-sound.pa
CONFIG_FILE=/etc/pulse/balena-sound.pa

# Set loopback module latency
function set_loopback_latency() {
local LOOPBACK="$1"
local LATENCY="$2"

sed -i "s/%$LOOPBACK%/$LATENCY/" "$CONFIG_FILE"
}

# Route "balena-sound.input" to the appropriate sink depending on selected mode
# Either "snapcast" fifo sink or "balena-sound.output"
function route_input_sink() {
Expand Down Expand Up @@ -72,11 +80,17 @@ while ! curl --silent --output /dev/null "$SOUND_SUPERVISOR/ping"; do sleep 5; e
SOUND_SUPERVISOR="$(ip route | awk '/default / { print $3 }'):3000"
MODE=$(curl --silent "$SOUND_SUPERVISOR/mode" || true)

# Get latency values
SOUND_INPUT_LATENCY=${SOUND_INPUT_LATENCY:-200}
SOUND_OUPUT_LATENCY=${SOUND_OUTPUT_LATENCY:-200}

# Audio routing: route intermediate balena-sound input/output sinks
echo "Setting audio routing rules. Note that this can be changed after startup."
reset_sound_config
route_input_sink "$MODE"
route_output_sink
set_loopback_latency "INPUT_LATENCY" "$SOUND_INPUT_LATENCY"
set_loopback_latency "OUTPUT_LATENCY" "$SOUND_OUPUT_LATENCY"
if [[ -n "$SOUND_ENABLE_SOUNDCARD_INPUT" ]]; then
route_input_source
fi
Expand Down
8 changes: 5 additions & 3 deletions core/multiroom/client/Dockerfile.template
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine:3.12
# Minimum snapcast version for ALSA stream source is v0.21
# Currently Alpine 3.12 is pinned to snapcast v0.19 so we need to use Alpine edge
FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine:edge
WORKDIR /usr/src

RUN install_packages snapcast-client

# Audio block setup
RUN curl --silent https://raw.githubusercontent.com/balenablocks/audio/master/scripts/alsa-bridge/alpine-setup.sh | sh
ENV PULSE_SERVER=tcp:audio:4317
ENV PULSE_SINK=balena-sound.output
RUN curl --silent https://raw.githubusercontent.com/balenablocks/audio/master/scripts/alsa-bridge/alpine-setup.sh | sh

WORKDIR /usr/src
COPY start.sh .

CMD [ "/bin/bash", "/usr/src/start.sh" ]
5 changes: 1 addition & 4 deletions core/multiroom/client/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ echo "Target snapcast server: $SNAPSERVER"

# Start snapclient
if [[ "$MODE" == "MULTI_ROOM" || "$MODE" == "MULTI_ROOM_CLIENT" ]]; then
# Start snapclient and filter out those pesky chunk logs
# grep filter can be removed when we get snapcast v0.20
# see: https://github.com/badaix/snapcast/issues/559#issuecomment-615874719
/usr/bin/snapclient --host $SNAPSERVER $LATENCY | grep -v "\[Info\] (Stream) Chunk"
/usr/bin/snapclient --host $SNAPSERVER $LATENCY --logfilter *:notice
else
echo "Multi-room client disabled. Exiting..."
exit 0
Expand Down
28 changes: 23 additions & 5 deletions core/multiroom/server/Dockerfile.template
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine:3.12
# Build snapweb separately
FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine-node:latest as web-builder
WORKDIR /usr/src

RUN install_packages git make npm

RUN git clone https://github.com/badaix/snapweb.git snapweb
RUN npm install --global --no-save typescript
RUN cd snapweb && make

# Minimum snapcast version for ALSA stream source is v0.21
# Currently Alpine 3.12 is pinned to snapcast v0.19 so we need to use Alpine edge
FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine:edge
WORKDIR /usr/src

RUN install_packages snapcast-server git make npm
# Install snapweb
RUN mkdir -p /var/www
COPY --from=web-builder /usr/src/snapweb/dist/* /var/www/

# Install snapcast
RUN install_packages snapcast-server
COPY snapserver.conf /etc/snapserver.conf
COPY start.sh .
RUN git clone https://github.com/badaix/snapweb.git snapweb
RUN npm install --global --no-save typescript
RUN cd snapweb && make && mkdir -p /var/www && mv dist/* /var/www

# Audio block setup
ENV PULSE_SERVER=tcp:audio:4317
ENV PULSE_SOURCE=snapcast.monitor
RUN curl --silent https://raw.githubusercontent.com/balenablocks/audio/master/scripts/alsa-bridge/alpine-setup.sh| sh

CMD [ "/bin/bash", "/usr/src/start.sh" ]
4 changes: 3 additions & 1 deletion core/multiroom/server/snapserver.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ port = 1780
doc_root = /var/www/

[stream]
stream = pipe:///var/cache/snapcast/snapfifo?name=balenaSound
stream = alsa://?name=balenaSound&device=pulse
sampleformat = 44100:16:2

[logging]
filter = *:notice
5 changes: 0 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ version: '2'

volumes:
spotifycache:
snapcast:

services:

Expand All @@ -14,8 +13,6 @@ services:
io.balena.features.dbus: 1
ports:
- 4317:4317
volumes:
- snapcast:/var/cache/snapcast

sound-supervisor:
build: ./core/sound-supervisor
Expand All @@ -32,8 +29,6 @@ services:
- 1704:1704
- 1705:1705
- 1780:1780
volumes:
- snapcast:/var/cache/snapcast

multiroom-client:
build: ./core/multiroom/client
Expand Down
3 changes: 3 additions & 0 deletions docs/03-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ The following environment variables apply to balenaSound in general, modifying i
| SOUND_VOLUME | Output volume level at startup. | 0 - 100, integer value without the `%` symbol. | 75 |
| SOUND_DEVICE_NAME / BLUETOOTH_DEVICE_NAME | Device name to be advertised by plugins (AirPlay device list, Spotify Connect and UPnP). For bluetooth use `BLUETOOTH_DEVICE_NAME` | Any valid string. | `balenaSound <plugin> <xxxx>`, where:<br>- `<plugin>` is `Spotify, AirPlay, UPnP`<br>- `<xxxx>` the first 4 chars of the device UUID. |
| AUDIO_OUTPUT | Select the default audio output interface. See [audio block](https://github.com/balenablocks/audio/blob/master/README.md#environment-variables). | For all device types: <br>- `AUTO`: Automatic detection. Priority is `USB > DAC > HEADPHONES > HDMI`<br>- `DAC`: Force default output to be an attached GPIO based DAC<br><br> For Raspberry Pi devices: <br>- `RPI_AUTO`: Official BCM2835 automatic audio switching as described [here](https://www.raspberrypi.org/documentation/configuration/audio-config.md) <br>- `RPI_HEADPHONES`: 3.5mm audio jack <br>- `RPI_HDMI0`: Main HDMI port <br>- `RPI_HDMI1`: Secondary HDMI port (only Raspberry Pi 4) <br><br> For Intel NUC: <br>- NUCs have automatic output detection and switching. If you plug both the HDMI and the 3.5mm audio jack it will use the latter. | `AUTO` |
| SOUND_INPUT_LATENCY | Input loopback latency in milliseconds. Useful when experiencing frequent audio stuttering due to underruns. Note that this is only a friendly request, the actual latency might be higher. | 1 - 2000. | 200 |
| SOUND_OUTPUT_LATENCY | Output loopback latency in milliseconds. Note that this is only a friendly request, the actual latency might be higher. | 1 - 2000. | 200 |


## Multi-room

Expand Down

0 comments on commit d3312c2

Please sign in to comment.