|
| 1 | +# Splitting a Surround Card into Per-Room Stereo Outputs |
| 2 | + |
| 3 | +A single surround or multichannel sound card -- a 5.1/7.1 HDMI or analog card, or an 8-channel USB interface -- exposes many output channels on one physical device. Often you want to treat those channels as several independent stereo "rooms": each stereo pair of the card's outputs feeds one room's speakers, with a *different* program driving each pair at the same time. |
| 4 | + |
| 5 | +This is the natural companion to running several Shairport Sync instances on one host (see [`RunningMultipleInstances.md`](RunningMultipleInstances.md)). That guide explains that each instance needs its own output device; this guide shows how to carve one multichannel card into several stereo ALSA PCMs so each instance can own one. |
| 6 | + |
| 7 | +All of the work here is ALSA configuration. Shairport Sync itself needs no special build -- it just points at the named PCM you create. |
| 8 | + |
| 9 | +## The problem |
| 10 | + |
| 11 | +You cannot simply open the raw hardware device (`hw:0`) from several programs at once. A raw `hw` device is *exclusive*: the first program to open it locks it, and every other program gets a "device busy" error. To let several programs share one card you need a software layer that mixes or shares the device between clients, and some way to steer each client's two channels onto a specific pair of the card's output channels. |
| 12 | + |
| 13 | +ALSA provides exactly these pieces: |
| 14 | + |
| 15 | +- **`dshare`** -- lets several clients share one card, each writing to a *different* set of the card's channels. This is what gives each room its own pair. |
| 16 | +- **`bindings`** -- inside a `dshare`, map this client's two channels onto chosen channels of the card. |
| 17 | +- **`plug`** -- transparently converts rate, format, and channel count so that ordinary stereo sources work. |
| 18 | +- **`dmix`** -- the sibling of `dshare` that *mixes* several clients onto the *same* channels. You don't need it to split a card into rooms, but it's the piece to reach for if you want two sources to share one output -- for example a chime or announcement played over whatever is already in a room. Point both sources at a `dmix` on those channels instead of a `dshare`. |
| 19 | + |
| 20 | +You combine these into one named PCM per room. |
| 21 | + |
| 22 | +## General ALSA configuration |
| 23 | + |
| 24 | +Put the following in `/etc/asound.conf` (system-wide) or `~/.asoundrc` (per-user). The two files use identical syntax. |
| 25 | + |
| 26 | +### Define the card once |
| 27 | + |
| 28 | +Define the hardware as a named slave, so every room can refer to it without repeating the device, channel count and rate: |
| 29 | + |
| 30 | +``` |
| 31 | +pcm_slave.card { |
| 32 | + pcm "hw:0,0" # the real multichannel card |
| 33 | + channels 6 # the card's output channels (6 for 5.1, 8 for 7.1) |
| 34 | + rate 44100 |
| 35 | +} |
| 36 | +``` |
| 37 | + |
| 38 | +### One stereo PCM per room |
| 39 | + |
| 40 | +Now define a PCM per room. Each is a `dshare` bound to two of the card's channels -- `dshare` gives each room its own channels and, unlike `dmix`, does not mix them -- wrapped in `plug` so any stereo source is converted automatically. A 5.1 card is three stereo output jacks, so it gives three rooms; name each after the jack it drives: |
| 41 | + |
| 42 | +``` |
| 43 | +pcm.room_front { |
| 44 | + type plug |
| 45 | + slave.pcm { |
| 46 | + type dshare |
| 47 | + ipc_key 4242 # same key for every room on this card |
| 48 | + ipc_key_add_uid false |
| 49 | + slave card |
| 50 | + bindings.0 0 # room left -> channel 0 (front left) |
| 51 | + bindings.1 1 # room right -> channel 1 (front right) |
| 52 | + } |
| 53 | +} |
| 54 | +
|
| 55 | +pcm.room_center { |
| 56 | + type plug |
| 57 | + slave.pcm { |
| 58 | + type dshare |
| 59 | + ipc_key 4242 |
| 60 | + ipc_key_add_uid false |
| 61 | + slave card |
| 62 | + bindings.0 2 # -> channel 2 (centre) |
| 63 | + bindings.1 3 # -> channel 3 (LFE / subwoofer) |
| 64 | + } |
| 65 | +} |
| 66 | +
|
| 67 | +pcm.room_rear { |
| 68 | + type plug |
| 69 | + slave.pcm { |
| 70 | + type dshare |
| 71 | + ipc_key 4242 |
| 72 | + ipc_key_add_uid false |
| 73 | + slave card |
| 74 | + bindings.0 4 # -> channel 4 (rear left) |
| 75 | + bindings.1 5 # -> channel 5 (rear right) |
| 76 | + } |
| 77 | +} |
| 78 | +``` |
| 79 | + |
| 80 | +`bindings.0 4 / bindings.1 5` put the room's left and right onto the card's channels 4 and 5. Every room on the one card uses the **same `ipc_key`**, so they attach to the same shared device and play at once, each on its own channels -- the rooms do not need a key each. `ipc_key_add_uid false` keeps that key the same for every user, which matters when the rooms run as separate containers (see below). `room_center` just uses the centre/subwoofer jack (channels 2 and 3) as a plain stereo output. |
| 81 | + |
| 82 | +> **Check your channel order.** The mapping above assumes the common layout `FL FR FC LFE RL RR (SL SR)`. Cards can differ, so confirm which physical output each channel drives -- e.g. `speaker-test -D hw:0,0 -c 6 -t wav`, which plays each channel in turn and names its position -- and adjust the `bindings` to match. |
| 83 | +
|
| 84 | +### Adding rooms on a 7.1 / 8-channel card |
| 85 | + |
| 86 | +An 8-channel card has a fourth stereo pair -- the side channels. Set `channels 8` in `pcm_slave.card` (above), then add a fourth room alongside `room_front`, `room_center` and `room_rear`: |
| 87 | + |
| 88 | +``` |
| 89 | +pcm.room_side { |
| 90 | + type plug |
| 91 | + slave.pcm { |
| 92 | + type dshare |
| 93 | + ipc_key 4242 |
| 94 | + ipc_key_add_uid false |
| 95 | + slave card |
| 96 | + bindings.0 6 # -> channel 6 (side left) |
| 97 | + bindings.1 7 # -> channel 7 (side right) |
| 98 | + } |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +### Pointing Shairport Sync at a room |
| 103 | + |
| 104 | +In the configuration file, name the PCM with the `output_device` setting in the `alsa` section: |
| 105 | + |
| 106 | +``` |
| 107 | +alsa = { |
| 108 | + output_device = "room_front"; |
| 109 | +}; |
| 110 | +``` |
| 111 | + |
| 112 | +Or on the command line: |
| 113 | + |
| 114 | +``` |
| 115 | +shairport-sync -o alsa -- -d room_front |
| 116 | +``` |
| 117 | + |
| 118 | +Give each instance its own room PCM and they will play independently through the one card. |
| 119 | + |
| 120 | +## Doing the split across Docker containers |
| 121 | + |
| 122 | +The scheme above works unchanged on a bare host. In containers -- for example one Shairport Sync instance per container -- three things have to be true inside *each* container: |
| 123 | + |
| 124 | +1. **The ALSA configuration must be visible.** Bind-mount your config read-only, either `/etc/asound.conf` or `~/.asoundrc`. |
| 125 | +2. **The device nodes must be reachable.** Pass `--device /dev/snd` (or a device-cgroup rule), and make sure the container's user is in the `audio` group. |
| 126 | +3. **A matching `libasound` must be present** in the image so the plugins above can be parsed and loaded. |
| 127 | + |
| 128 | +There is one more, less obvious requirement. A `dshare` device coordinates its clients through **System V IPC** -- the shared-memory segment and semaphore named by `ipc_key`. System V IPC lives in an **IPC namespace**, and by default every container gets its own. So even with the same `ipc_key`, a client in container A and a client in container B land in *different* IPC namespaces, each creates its own private state, and they end up fighting over the card: expect xruns, "device busy", or one container silently winning the device. |
| 129 | + |
| 130 | +The fix is to put the containers in a **shared IPC namespace** so that one `ipc_key` resolves to one IPC object for all of them: |
| 131 | + |
| 132 | +- With `docker run`, give every container `--ipc host`, or start one "owner" and join the rest with `--ipc container:<name>`. |
| 133 | +- In Compose, set `ipc: host` (or `ipc: "service:<name>"`) on each service. |
| 134 | + |
| 135 | +Also keep `ipc_key_add_uid false` in each `dshare` definition (as above) and use an explicit `ipc_key`, so every container's client deterministically resolves to the same object regardless of UID. |
| 136 | + |
| 137 | +Finally, `/dev/snd` must refer to the *same* physical card in each container. With host device access (`--device /dev/snd`) it does. |
| 138 | + |
| 139 | +A runnable example combines the ALSA bits above (`/dev/snd`, the `audio` group, the shared `asound.conf`) with the AirPlay 2 essentials from the [companion guide](RunningMultipleInstances.md) -- host networking, a distinct name and port per room, and one shared NQPTP (`ENABLE_NQPTP=0` on the rooms): |
| 140 | + |
| 141 | +```yaml |
| 142 | +services: |
| 143 | + nqptp: # one shared nqptp for the whole host |
| 144 | + image: mikebrady/shairport-sync:latest |
| 145 | + entrypoint: ["/usr/local/bin/nqptp"] |
| 146 | + network_mode: host |
| 147 | + |
| 148 | + room_front: |
| 149 | + image: mikebrady/shairport-sync:latest |
| 150 | + network_mode: host # AirPlay discovery, and reaching nqptp |
| 151 | + ipc: host # shared /dev/shm for nqptp AND shared IPC for dshare |
| 152 | + environment: [ENABLE_NQPTP=0] # use the shared nqptp above |
| 153 | + devices: |
| 154 | + - "/dev/snd:/dev/snd" |
| 155 | + group_add: |
| 156 | + - audio |
| 157 | + volumes: |
| 158 | + - "./asound.conf:/etc/asound.conf:ro" |
| 159 | + command: ["-a", "Front Room", "--port=7000", "--", "-d", "room_front"] |
| 160 | + depends_on: [nqptp] |
| 161 | + |
| 162 | + room_rear: |
| 163 | + image: mikebrady/shairport-sync:latest |
| 164 | + network_mode: host |
| 165 | + ipc: host |
| 166 | + environment: [ENABLE_NQPTP=0] |
| 167 | + devices: |
| 168 | + - "/dev/snd:/dev/snd" |
| 169 | + group_add: |
| 170 | + - audio |
| 171 | + volumes: |
| 172 | + - "./asound.conf:/etc/asound.conf:ro" |
| 173 | + command: ["-a", "Rear Room", "--port=7001", "--", "-d", "room_rear"] |
| 174 | + depends_on: [nqptp] |
| 175 | +``` |
| 176 | +
|
| 177 | +The equivalent for one room with plain `docker run` (with the shared `nqptp` already running): |
| 178 | + |
| 179 | +``` |
| 180 | +docker run --network host --ipc host -e ENABLE_NQPTP=0 \ |
| 181 | + --device /dev/snd --group-add audio \ |
| 182 | + -v "$PWD/asound.conf:/etc/asound.conf:ro" \ |
| 183 | + mikebrady/shairport-sync:latest -a "Front Room" --port=7000 -- -d room_front |
| 184 | +``` |
| 185 | +
|
| 186 | +`ipc: host` does double duty here: it shares the host's `/dev/shm` so the rooms read the one nqptp's clock, and it shares the System V IPC namespace so the per-room `dshare` devices resolve to one shared card. The same `asound.conf` (its shared card slave and per-room `dshare` PCMs) goes into every container. |
| 187 | +
|
| 188 | +## A note on multiple USB sound cards |
| 189 | +
|
| 190 | +If your rooms span several USB cards rather than one multichannel card, do not rely on `hw:0` / `hw:1`: USB devices enumerate in whatever order the kernel probes them, so the indices can change between boots. Pin each card by its *name* and reference it as `hw:CARD=<name>` in its slave -- the name is the one shown by `cat /proc/asound/cards` or `aplay -l`: |
| 191 | +
|
| 192 | +``` |
| 193 | +pcm_slave.card { |
| 194 | + pcm "hw:CARD=Device_1,0" |
| 195 | + channels 6 |
| 196 | + rate 44100 |
| 197 | +} |
| 198 | +``` |
| 199 | +
|
| 200 | +A second card is just a second `pcm_slave` with its own `hw:CARD=...` and its own `ipc_key`; its rooms bind to it exactly the same way. Alternatively, fix each card's index with a `modprobe`/`modules-load.d` option for `snd-usb-audio`. See the [ALSA wiki](https://www.alsa-project.org/wiki/Asoundrc) for the full details. |
| 201 | +
|
| 202 | +--- |
| 203 | +
|
| 204 | +See also [`RunningMultipleInstances.md`](RunningMultipleInstances.md), the companion guide on running several Shairport Sync instances -- one per room -- on a single host. |
0 commit comments