This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / configs / gg23 / audio / hub.nix
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}