This repository has no description
9.4 kB
292 lines
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). Senders: any LAN machine via ROC (while `gg23-audio` from
4# tools/gg23-audio runs), barnacle via AirPlay 2 (shairport-sync + nqptp),
5# phones via Bluetooth A2DP (paired during the window opened by the
6# "Bluetooth pairing" button). Rooms run ./room.nix.
7{
8 self,
9 config,
10 lib,
11 pkgs,
12 ...
13}:
14let
15 # shodan and icarus have no stable LAN address (dynamic DHCP leases), so
16 # send to their static retiolum IPs; tinc's LocalDiscovery keeps that on the
17 # 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 };
26
27 mqtt = "-h localhost -u gg23 -P gg23-mqtt";
28
29 # Home Assistant shows a switch per source and room (./matrix.py); the
30 # fixed sources by node name, Bluetooth phones come and go
31 matrixConfig = pkgs.writeText "audio-matrix.json" (
32 builtins.toJSON {
33 mqtt = {
34 host = "localhost";
35 user = "gg23";
36 password = "gg23-mqtt";
37 };
38 speakers = lib.mapAttrs (r: label: {
39 node = "roc-${r}";
40 inherit label;
41 }) rooms;
42 sources = {
43 airplay = {
44 node = "Shairport Sync";
45 label = "AirPlay";
46 };
47 lan = {
48 node = "roc-lan";
49 label = "LAN";
50 };
51 };
52 state = "/var/lib/audio-matrix/routes.json";
53 }
54 );
55
56 # seconds acheron stays discoverable and pairable after the button press
57 pairingTimeout = 120;
58
59 bluetoothDiscovery = [
60 {
61 topic = "homeassistant/button/bluetooth_pairing/config";
62 config = {
63 name = "Bluetooth pairing";
64 unique_id = "acheron_bluetooth_pairing";
65 command_topic = "gg23/bluetooth-pairing/set";
66 icon = "mdi:bluetooth-connect";
67 };
68 }
69 {
70 topic = "homeassistant/binary_sensor/bluetooth_pairing/config";
71 config = {
72 name = "Bluetooth pairing";
73 unique_id = "acheron_bluetooth_pairing_state";
74 state_topic = "gg23/bluetooth-pairing/state";
75 icon = "mdi:bluetooth-connect";
76 };
77 }
78 ];
79in
80{
81 services.pipewire = {
82 enable = true;
83 systemWide = true;
84 audio.enable = true;
85 extraConfig.pipewire."10-audio-hub" = {
86 "context.properties"."default.clock.quantum" = 256;
87 "context.modules" = [
88 {
89 # from any LAN sender (`gg23-audio`); roc mixes concurrent
90 # sender sessions. Only reachable via int0, et0 is closed.
91 name = "libpipewire-module-roc-source";
92 args = {
93 "local.ip" = "0.0.0.0";
94 "local.source.port" = 10001;
95 "local.repair.port" = 10002;
96 "local.control.port" = 10003;
97 "fec.code" = "disable";
98 "sess.latency.msec" = 30;
99 "source.name" = "roc-lan";
100 "source.props" = {
101 "node.description" = "LAN (ROC)";
102 "node.autoconnect" = false;
103 };
104 };
105 }
106 ]
107 ++ map (r: {
108 name = "libpipewire-module-roc-sink";
109 args = {
110 "remote.ip" = ip4 r;
111 "remote.source.port" = 10001;
112 "remote.repair.port" = 10002;
113 "remote.control.port" = 10003;
114 "fec.code" = "disable";
115 "sink.name" = "roc-${r}";
116 "sink.props"."node.description" = "${r} (ROC)";
117 };
118 }) (lib.attrNames rooms);
119 };
120 # acheron is only an A2DP sink (no headset or LE audio roles); like the
121 # other sources, phones are linked only by audio-matrix
122 wireplumber.extraConfig."10-bluetooth-hub" = {
123 "monitor.bluez.properties" = {
124 "bluez5.roles" = [ "a2dp_sink" ];
125 # phones in HD audio mode pick other codecs, whose input node fails
126 "bluez5.codecs" = [
127 "sbc"
128 "sbc_xq"
129 "aac"
130 ];
131 };
132 "monitor.bluez.rules" = [
133 {
134 matches = [ { "node.name" = "~bluez_input.*"; } ];
135 actions.update-props."node.autoconnect" = false;
136 }
137 ];
138 };
139 };
140
141 # The UB500 dongle (RTL8761BU) firmware comes from linux-firmware.
142 # Discoverable and pairable only after the "Bluetooth pairing" button.
143 hardware.bluetooth = {
144 enable = true;
145 # the hostname plugin would override Name and Class with the hostname
146 # and chassis type
147 disabledPlugins = [ "hostname" ];
148 settings.General = {
149 Name = "gg23";
150 # audio/video major class, loudspeaker, so phones show a speaker
151 Class = "0x200414";
152 DiscoverableTimeout = pairingTimeout;
153 PairableTimeout = pairingTimeout;
154 # a phone that forgot acheron can pair again during the window
155 JustWorksRepairing = "always";
156 };
157 };
158
159 # Accepts pairing without PIN (NoInputNoOutput is just works) and service
160 # connections of paired devices.
161 systemd.services.bluetooth-agent = {
162 wantedBy = [ "bluetooth.service" ];
163 bindsTo = [ "bluetooth.service" ];
164 after = [ "bluetooth.service" ];
165 serviceConfig = {
166 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput";
167 DynamicUser = true;
168 Restart = "always";
169 RestartSec = 2;
170 };
171 };
172
173 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path
174 # that can wedge (endpoint stays half-active after switching outputs, and
175 # re-picking it fails with -16727 until AirPlayXPCHelper restarts).
176 services.shairport-sync = {
177 enable = true;
178 package = pkgs.shairport-sync-airplay2;
179 settings = {
180 general = {
181 name = "gg23";
182 output_backend = "pipewire";
183 # play early by about the acheron -> room ROC hop, so AirPlay video
184 # stays in sync in the rooms
185 audio_backend_latency_offset_in_seconds = -0.04;
186 };
187 };
188 };
189 users.users.shairport.extraGroups = [ "pipewire" ];
190
191 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0
192 # stays closed). The unit comes from the package.
193 systemd.packages = [ pkgs.nqptp ];
194 systemd.services.nqptp.wantedBy = [ "multi-user.target" ];
195 systemd.services.shairport-sync = {
196 wants = [ "nqptp.service" ];
197 after = [ "nqptp.service" ];
198 # linked only by audio-matrix
199 environment.PIPEWIRE_AUTOCONNECT = "false";
200 };
201
202 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed
203 services.avahi = {
204 allowInterfaces = [ "int0" ];
205 openFirewall = false;
206 };
207
208 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to
209 # pipewire. Restarted with pipewire, the state file restores the routes.
210 systemd.services.audio-matrix = {
211 wantedBy = [
212 "multi-user.target"
213 "pipewire.service"
214 ];
215 bindsTo = [ "pipewire.service" ];
216 after = [
217 "pipewire.service"
218 "mosquitto.service"
219 ];
220 path = [ config.services.pipewire.package ];
221 serviceConfig = {
222 ExecStart = "${
223 pkgs.writers.writePython3 "audio-matrix" {
224 libraries = [ pkgs.python3Packages.paho-mqtt ];
225 flakeIgnore = [ "E501" ];
226 } ./matrix.py
227 } ${matrixConfig}";
228 DynamicUser = true;
229 SupplementaryGroups = [ "pipewire" ];
230 StateDirectory = "audio-matrix";
231 Restart = "always";
232 RestartSec = 2;
233 };
234 };
235
236 # MQTT button -> pairing window. BlueZ itself turns discoverable/pairable
237 # off after pairingTimeout; then devices bonded in the window get trusted,
238 # so they reconnect without the agent, and the state goes back to OFF.
239 systemd.services.bluetooth-pairing = {
240 wantedBy = [ "bluetooth.service" ];
241 bindsTo = [ "bluetooth.service" ];
242 after = [
243 "bluetooth.service"
244 "mosquitto.service"
245 ];
246 path = [
247 pkgs.mosquitto
248 config.systemd.package
249 ];
250 script = ''
251 ${lib.concatMapStrings (d: ''
252 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)}
253 '') bluetoothDiscovery}
254 adapter() {
255 busctl set-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 "$1" b "$2"
256 }
257 state() {
258 mosquitto_pub ${mqtt} -r -t gg23/bluetooth-pairing/state -m "$1"
259 }
260 trust() {
261 for d in $(busctl tree --list org.bluez | grep '/dev_[0-9A-F_]*$'); do
262 if [ "$(busctl get-property org.bluez "$d" org.bluez.Device1 Bonded)" = "b true" ]; then
263 busctl set-property org.bluez "$d" org.bluez.Device1 Trusted b true
264 fi
265 done
266 }
267 # bluetoothd powers hci0 on (AutoEnable) only after it shows up
268 until [ "$(busctl get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Powered 2>/dev/null)" = "b true" ]; do
269 sleep 1
270 done
271 adapter Discoverable false
272 adapter Pairable false
273 trust
274 state OFF
275 mosquitto_sub ${mqtt} -t gg23/bluetooth-pairing/set | while read -r _; do
276 if [ -n "''${window-}" ] && kill -0 "$window" 2>/dev/null; then
277 continue
278 fi
279 adapter Pairable true
280 adapter Discoverable true
281 state ON
282 (sleep ${toString pairingTimeout}; trust; state OFF) &
283 window=$!
284 done
285 '';
286 serviceConfig = {
287 DynamicUser = true;
288 Restart = "always";
289 RestartSec = 2;
290 };
291 };
292}