This repository has no description
1# Low-latency audio hub on acheron: audio-matrix (./matrix.py) links each
2# received source to the ROC sinks of the rooms switched on for it in Home
3# Assistant (MQTT), which also sets a volume per room. Sources: any LAN
4# machine via ROC (while `gg23-audio` from tools/gg23-audio runs), barnacle
5# via AirPlay 2 (shairport-sync + nqptp), phones via Bluetooth A2DP (paired
6# during the window opened by the "Bluetooth pairing" button) and
7# radio.lassul.us. Rooms run ./room.nix.
8{
9 self,
10 config,
11 lib,
12 pkgs,
13 ...
14}:
15let
16 # Rooms are addressed by their static retiolum IPs (shodan and icarus have
17 # dynamic DHCP leases); tinc's LocalDiscovery keeps that on the LAN.
18 kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; };
19 ip4 = n: kartei.own.${n}.ip4;
20
21 # room machine -> speaker name in Home Assistant
22 rooms = {
23 shodan = "Kitchen";
24 icarus = "Bath";
25 styx = "Living room";
26 };
27
28 mqtt = "-h localhost -u gg23 -P gg23-mqtt";
29
30 # Home Assistant shows a switch per source and room and a volume per room
31 # (./matrix.py); the fixed sources by node name, Bluetooth phones come and
32 # go. A source starts on all rooms unless it lists `rooms`.
33 matrixConfig = pkgs.writeText "audio-matrix.json" (
34 builtins.toJSON {
35 mqtt = {
36 host = "localhost";
37 user = "gg23";
38 password = "gg23-mqtt";
39 };
40 speakers = lib.mapAttrs (r: label: {
41 node = "roc-${r}";
42 inherit label;
43 }) rooms;
44 sources = {
45 airplay = {
46 node = "Shairport Sync";
47 label = "AirPlay";
48 };
49 lan = {
50 node = "roc-lan";
51 label = "LAN";
52 };
53 # where Snapcast played it before
54 radio = {
55 node = "radio";
56 label = "Radio";
57 rooms = [
58 "icarus"
59 "shodan"
60 ];
61 };
62 };
63 state = "/var/lib/audio-matrix/routes.json";
64 volumes = "/var/lib/audio-matrix/volumes.json";
65 }
66 );
67
68 # seconds acheron stays discoverable and pairable after the button press
69 pairingTimeout = 120;
70
71 bluetoothDiscovery = [
72 {
73 topic = "homeassistant/button/bluetooth_pairing/config";
74 config = {
75 name = "Bluetooth pairing";
76 unique_id = "acheron_bluetooth_pairing";
77 command_topic = "gg23/bluetooth-pairing/set";
78 icon = "mdi:bluetooth-connect";
79 };
80 }
81 {
82 topic = "homeassistant/binary_sensor/bluetooth_pairing/config";
83 config = {
84 name = "Bluetooth pairing";
85 unique_id = "acheron_bluetooth_pairing_state";
86 state_topic = "gg23/bluetooth-pairing/state";
87 icon = "mdi:bluetooth-connect";
88 };
89 }
90 ];
91
92 # "Audio" dashboard in Home Assistant (configs/hass loads /config/packages):
93 # one grid of a column per room (volume, all off) and a row per source
94 # (all on/off), cells toggle the audio_route switches. Rows come from the
95 # switches of the first room via auto-entities (HACS), so Bluetooth phones
96 # appear while connected.
97 roomNames = lib.attrNames rooms;
98 anchor = builtins.head roomNames;
99 # button calling `service` on the route switches in Jinja list `targets`
100 allButton =
101 name: icon: service: targets:
102 ''{"type": "button", "name": "${name}", "icon": "${icon}", "show_name": true, "icon_height": "24px", "tap_action": {"action": "perform-action", "perform_action": "${service}", "target": {"entity_id": ${targets}}}, "hold_action": {"action": "none"}}'';
103 roomHeader =
104 r:
105 ''{% set col = states.switch | selectattr('entity_id', 'search', '^switch\\.audio_route_.*_${r}$') | map(attribute='entity_id') | list %}{% set ns.out = ns.out + [{"type": "vertical-stack", "cards": [{"type": "tile", "entity": "number.audio_volume_${r}", "name": "${rooms.${r}}", "icon": "mdi:speaker", "features": [{"type": "numeric-input", "style": "slider"}]}, ${
106 allButton "All off" "mdi:volume-off" "switch.turn_off" "col"
107 }]}] %}'';
108 # crossed out speaker when off, so on/off differ in shape too
109 routeCell =
110 r:
111 let
112 id = ''"switch.audio_route_" ~ k ~ "_${r}"'';
113 in
114 ''{"type": "button", "entity": ${id}, "name": "${rooms.${r}}", "show_name": false, "icon": ("mdi:speaker" if is_state(${id}, "on") else "mdi:speaker-off"), "icon_height": "64px", "tap_action": {"action": "toggle"}, "hold_action": {"action": "none"}}'';
115 matrix = lib.concatStrings (
116 [
117 "{% set ns = namespace(out=[]) %}"
118 ''{% set ns.out = ns.out + [{"type": "markdown", "content": "### Source / Room"}] %}''
119 ]
120 ++ map roomHeader roomNames
121 ++ [
122 "{% for e in states.switch | selectattr('entity_id', 'search', '^switch\\\\.audio_route_.*_${anchor}$') | sort(attribute='attributes.source') | sort(attribute='attributes.kind') %}"
123 "{% set icon = {'bluetooth': 'mdi:bluetooth', 'airplay': 'mdi:cast-audio', 'lan': 'mdi:lan', 'radio': 'mdi:radio'}.get(e.attributes.kind, 'mdi:music') %}"
124 # switch.audio_route_<key>_<anchor>
125 "{% set k = e.entity_id[${toString (builtins.stringLength "switch.audio_route_")}:-${toString (builtins.stringLength "_${anchor}")}] %}"
126 "{% set row = [${
127 lib.concatMapStringsSep ", " (r: ''"switch.audio_route_" ~ k ~ "_${r}"'') roomNames
128 }] %}"
129 ''{% set ns.out = ns.out + [{"type": "vertical-stack", "cards": [{"type": "button", "name": e.attributes.source, "icon": icon, "show_state": false, "icon_height": "40px", "tap_action": {"action": "none"}, "hold_action": {"action": "none"}}, {"type": "grid", "columns": 2, "square": false, "cards": [${
130 allButton "All on" "mdi:volume-high" "switch.turn_on" "row"
131 }, ${allButton "All off" "mdi:volume-off" "switch.turn_off" "row"}]}]}] %}''
132 "{% set ns.out = ns.out + [${lib.concatMapStringsSep ", " routeCell roomNames}] %}"
133 "{% endfor %}{{ ns.out }}"
134 ]
135 );
136 audioDashboard = {
137 title = "Audio";
138 views = [
139 {
140 title = "Audio";
141 path = "routing";
142 icon = "mdi:speaker-multiple";
143 type = "sections";
144 max_columns = 3;
145 sections = [
146 {
147 type = "grid";
148 column_span = 3;
149 cards = [
150 {
151 type = "heading";
152 heading = "Where does the audio go?";
153 icon = "mdi:speaker-multiple";
154 }
155 {
156 type = "markdown";
157 text_only = true;
158 content = "Tap a speaker to send that source there. Bluetooth `gg23`, AirPlay `gg23`, LAN via `s gg23-audio` and the radio; Bluetooth devices appear while connected.";
159 }
160 {
161 type = "custom:auto-entities";
162 grid_options.columns = "full";
163 card = {
164 type = "grid";
165 columns = 1 + builtins.length roomNames;
166 square = false;
167 };
168 card_param = "cards";
169 filter.template = matrix;
170 show_empty = true;
171 }
172 ];
173 }
174 {
175 type = "grid";
176 column_span = 3;
177 cards = [
178 {
179 type = "heading";
180 heading = "Bluetooth";
181 icon = "mdi:bluetooth";
182 heading_style = "subtitle";
183 }
184 {
185 type = "button";
186 name = "Pair Bluetooth (${toString pairingTimeout} s)";
187 icon = "mdi:bluetooth-connect";
188 show_state = false;
189 grid_options.columns = 6;
190 tap_action = {
191 action = "perform-action";
192 perform_action = "button.press";
193 target.entity_id = "button.bluetooth_pairing";
194 };
195 }
196 {
197 type = "tile";
198 entity = "binary_sensor.bluetooth_pairing";
199 name = "Pairing window";
200 grid_options.columns = 6;
201 }
202 ];
203 }
204 ];
205 }
206 ];
207 };
208 json = name: value: pkgs.writeText name (builtins.toJSON value);
209in
210{
211 virtualisation.oci-containers.containers.homeassistant.volumes = [
212 "${
213 json "audio.yaml" {
214 lovelace.dashboards.audio-routing = {
215 mode = "yaml";
216 title = "Audio";
217 icon = "mdi:speaker-multiple";
218 show_in_sidebar = true;
219 filename = "dashboards/audio.yaml";
220 };
221 }
222 }:/config/packages/audio.yaml:ro"
223 "${json "audio-dashboard.yaml" audioDashboard}:/config/dashboards/audio.yaml:ro"
224 ];
225
226 services.pipewire = {
227 enable = true;
228 systemWide = true;
229 audio.enable = true;
230 extraConfig.pipewire."10-audio-hub" = {
231 "context.properties"."default.clock.quantum" = 256;
232 "context.modules" = [
233 {
234 # from any LAN sender (`gg23-audio`); roc mixes concurrent
235 # sender sessions. Only reachable via int0, et0 is closed.
236 name = "libpipewire-module-roc-source";
237 args = {
238 "local.ip" = "0.0.0.0";
239 "local.source.port" = 10001;
240 "local.repair.port" = 10002;
241 "local.control.port" = 10003;
242 "fec.code" = "disable";
243 "sess.latency.msec" = 30;
244 "source.name" = "roc-lan";
245 "source.props" = {
246 "node.description" = "LAN (ROC)";
247 "node.autoconnect" = false;
248 };
249 };
250 }
251 ]
252 ++ map (r: {
253 name = "libpipewire-module-roc-sink";
254 args = {
255 "remote.ip" = ip4 r;
256 "remote.source.port" = 10001;
257 "remote.repair.port" = 10002;
258 "remote.control.port" = 10003;
259 "fec.code" = "disable";
260 "sink.name" = "roc-${r}";
261 "sink.props"."node.description" = "${r} (ROC)";
262 };
263 }) (lib.attrNames rooms);
264 };
265 # acheron is only an A2DP sink (no headset or LE audio roles); like the
266 # other sources, phones are linked only by audio-matrix
267 wireplumber.extraConfig."10-bluetooth-hub" = {
268 "monitor.bluez.properties" = {
269 "bluez5.roles" = [ "a2dp_sink" ];
270 # phones in HD audio mode pick other codecs, whose input node fails
271 "bluez5.codecs" = [
272 "sbc"
273 "sbc_xq"
274 "aac"
275 ];
276 };
277 "monitor.bluez.rules" = [
278 {
279 matches = [ { "node.name" = "~bluez_input.*"; } ];
280 actions.update-props."node.autoconnect" = false;
281 }
282 ];
283 };
284 };
285
286 # The UB500 dongle (RTL8761BU) firmware comes from linux-firmware.
287 # Discoverable and pairable only after the "Bluetooth pairing" button.
288 hardware.bluetooth = {
289 enable = true;
290 # the hostname plugin would override Name and Class with the hostname
291 # and chassis type
292 disabledPlugins = [ "hostname" ];
293 settings.General = {
294 Name = "gg23";
295 # audio/video major class, loudspeaker, so phones show a speaker
296 Class = "0x200414";
297 DiscoverableTimeout = pairingTimeout;
298 PairableTimeout = pairingTimeout;
299 # a phone that forgot acheron can pair again during the window
300 JustWorksRepairing = "always";
301 };
302 };
303
304 # Accepts pairing without PIN (NoInputNoOutput is just works) and service
305 # connections of paired devices.
306 systemd.services.bluetooth-agent = {
307 wantedBy = [ "bluetooth.service" ];
308 bindsTo = [ "bluetooth.service" ];
309 after = [ "bluetooth.service" ];
310 serviceConfig = {
311 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput";
312 DynamicUser = true;
313 Restart = "always";
314 RestartSec = 2;
315 };
316 };
317
318 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path
319 # that can wedge (endpoint stays half-active after switching outputs, and
320 # re-picking it fails with -16727 until AirPlayXPCHelper restarts).
321 services.shairport-sync = {
322 enable = true;
323 package = pkgs.shairport-sync-airplay2;
324 settings = {
325 general = {
326 name = "gg23";
327 output_backend = "pipewire";
328 # play early by about the acheron -> room ROC hop, so AirPlay video
329 # stays in sync in the rooms
330 audio_backend_latency_offset_in_seconds = -0.04;
331 };
332 };
333 };
334 users.users.shairport.extraGroups = [ "pipewire" ];
335
336 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0
337 # stays closed). The unit comes from the package.
338 systemd.packages = [ pkgs.nqptp ];
339 systemd.services.nqptp.wantedBy = [ "multi-user.target" ];
340 systemd.services.shairport-sync = {
341 wants = [ "nqptp.service" ];
342 after = [ "nqptp.service" ];
343 # linked only by audio-matrix
344 environment.PIPEWIRE_AUTOCONNECT = "false";
345 };
346
347 # radio.lassul.us as a hub source, playing all the time; audio-matrix
348 # decides where it is heard
349 systemd.services.radio = {
350 wantedBy = [
351 "multi-user.target"
352 "pipewire.service"
353 ];
354 bindsTo = [ "pipewire.service" ];
355 after = [
356 "pipewire.service"
357 "network-online.target"
358 ];
359 wants = [ "network-online.target" ];
360 # linked only by audio-matrix
361 environment.PIPEWIRE_AUTOCONNECT = "false";
362 serviceConfig = {
363 ExecStart = lib.escapeShellArgs [
364 (lib.getExe pkgs.mpv)
365 "--no-video"
366 "--no-terminal"
367 "--ao=pipewire"
368 # node.name, see `sources` in matrixConfig
369 "--audio-client-name=radio"
370 # keep going through stream hiccups and server restarts
371 "--loop-playlist=inf"
372 "http://radio.lassul.us/radio.ogg"
373 ];
374 DynamicUser = true;
375 SupplementaryGroups = [ "pipewire" ];
376 Restart = "always";
377 RestartSec = 5;
378 };
379 };
380
381 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed
382 services.avahi = {
383 allowInterfaces = [ "int0" ];
384 openFirewall = false;
385 };
386
387 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to
388 # pipewire. Restarted with pipewire, the state file restores the routes.
389 systemd.services.audio-matrix = {
390 wantedBy = [
391 "multi-user.target"
392 "pipewire.service"
393 ];
394 bindsTo = [ "pipewire.service" ];
395 after = [
396 "pipewire.service"
397 "mosquitto.service"
398 ];
399 path = [
400 config.services.pipewire.package
401 config.services.pipewire.wireplumber.package # wpctl, for room volumes
402 ];
403 serviceConfig = {
404 ExecStart = "${
405 pkgs.writers.writePython3 "audio-matrix" {
406 libraries = [ pkgs.python3Packages.paho-mqtt ];
407 flakeIgnore = [ "E501" ];
408 } ./matrix.py
409 } ${matrixConfig}";
410 DynamicUser = true;
411 SupplementaryGroups = [ "pipewire" ];
412 StateDirectory = "audio-matrix";
413 Restart = "always";
414 RestartSec = 2;
415 };
416 };
417
418 # MQTT button -> pairing window. BlueZ itself turns discoverable/pairable
419 # off after pairingTimeout; then devices bonded in the window get trusted,
420 # so they reconnect without the agent, and the state goes back to OFF.
421 systemd.services.bluetooth-pairing = {
422 wantedBy = [ "bluetooth.service" ];
423 bindsTo = [ "bluetooth.service" ];
424 after = [
425 "bluetooth.service"
426 "mosquitto.service"
427 ];
428 path = [
429 pkgs.mosquitto
430 config.systemd.package
431 ];
432 script = ''
433 ${lib.concatMapStrings (d: ''
434 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)}
435 '') bluetoothDiscovery}
436 adapter() {
437 busctl set-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 "$1" b "$2"
438 }
439 state() {
440 mosquitto_pub ${mqtt} -r -t gg23/bluetooth-pairing/state -m "$1"
441 }
442 trust() {
443 for d in $(busctl tree --list org.bluez | grep '/dev_[0-9A-F_]*$'); do
444 if [ "$(busctl get-property org.bluez "$d" org.bluez.Device1 Bonded)" = "b true" ]; then
445 busctl set-property org.bluez "$d" org.bluez.Device1 Trusted b true
446 fi
447 done
448 }
449 # bluetoothd powers hci0 on (AutoEnable) only after it shows up
450 until [ "$(busctl get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Powered 2>/dev/null)" = "b true" ]; do
451 sleep 1
452 done
453 adapter Discoverable false
454 adapter Pairable false
455 trust
456 state OFF
457 mosquitto_sub ${mqtt} -t gg23/bluetooth-pairing/set | while read -r _; do
458 if [ -n "''${window-}" ] && kill -0 "$window" 2>/dev/null; then
459 continue
460 fi
461 adapter Pairable true
462 adapter Discoverable true
463 state ON
464 (sleep ${toString pairingTimeout}; trust; state OFF) &
465 window=$!
466 done
467 '';
468 serviceConfig = {
469 DynamicUser = true;
470 Restart = "always";
471 RestartSec = 2;
472 };
473 };
474}