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
6.5 kB 214 lines
1# Low-latency audio hub on acheron: everything received plays into the 2# virtual "hub" sink, whose monitor is linked to the ROC sinks of the rooms 3# selected with `audio-room` or from Home Assistant (MQTT). Senders: any LAN 4# machine via ROC (while `gg23-audio` from tools/gg23-audio runs), barnacle 5# via AirPlay 2 (shairport-sync + nqptp). Rooms run ./room.nix. 6{ 7 self, 8 config, 9 lib, 10 pkgs, 11 ... 12}: 13let 14 # shodan and icarus have no stable LAN address (dynamic DHCP leases), so 15 # send to their static retiolum IPs; tinc's LocalDiscovery keeps that on the 16 # LAN. 17 kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; }; 18 ip4 = n: kartei.own.${n}.ip4; 19 20 rooms = [ 21 "shodan" 22 "icarus" 23 ]; 24 choices = rooms ++ [ 25 "both" 26 "off" 27 ]; 28 29 mqtt = "-h localhost -u gg23 -P gg23-mqtt"; 30 31 # Home Assistant picks these up via MQTT discovery: a select showing the 32 # current room plus one button per choice (dashboard / phone shortcuts). 33 # Commands are retained, so the bridge restores the last choice on start. 34 discovery = [ 35 { 36 topic = "homeassistant/select/audio_room/config"; 37 config = { 38 name = "Audio room"; 39 unique_id = "acheron_audio_room"; 40 command_topic = "gg23/audio-room/set"; 41 state_topic = "gg23/audio-room/state"; 42 options = choices; 43 retain = true; 44 icon = "mdi:speaker-multiple"; 45 }; 46 } 47 ] 48 ++ map (c: { 49 topic = "homeassistant/button/audio_room_${c}/config"; 50 config = { 51 name = "Audio ${c}"; 52 unique_id = "acheron_audio_room_${c}"; 53 command_topic = "gg23/audio-room/set"; 54 payload_press = c; 55 retain = true; 56 icon = "mdi:speaker-multiple"; 57 }; 58 }) choices; 59in 60{ 61 services.pipewire = { 62 enable = true; 63 systemWide = true; 64 audio.enable = true; 65 extraConfig.pipewire."10-audio-hub" = { 66 "context.properties"."default.clock.quantum" = 256; 67 "context.objects" = [ 68 { 69 factory = "adapter"; 70 args = { 71 "factory.name" = "support.null-audio-sink"; 72 "node.name" = "hub"; 73 "node.description" = "Audio hub"; 74 "media.class" = "Audio/Sink"; 75 "audio.position" = [ 76 "FL" 77 "FR" 78 ]; 79 }; 80 } 81 ]; 82 "context.modules" = [ 83 { 84 # from any LAN sender (`gg23-audio`); roc mixes concurrent 85 # sender sessions. Only reachable via int0, et0 is closed. 86 name = "libpipewire-module-roc-source"; 87 args = { 88 "local.ip" = "0.0.0.0"; 89 "local.source.port" = 10001; 90 "local.repair.port" = 10002; 91 "local.control.port" = 10003; 92 "fec.code" = "disable"; 93 "sess.latency.msec" = 30; 94 "source.name" = "roc-lan"; 95 "source.props" = { 96 "node.description" = "LAN (ROC)"; 97 "target.object" = "hub"; 98 }; 99 }; 100 } 101 ] 102 ++ map (r: { 103 name = "libpipewire-module-roc-sink"; 104 args = { 105 "remote.ip" = ip4 r; 106 "remote.source.port" = 10001; 107 "remote.repair.port" = 10002; 108 "remote.control.port" = 10003; 109 "fec.code" = "disable"; 110 "sink.name" = "roc-${r}"; 111 "sink.props"."node.description" = "${r} (ROC)"; 112 }; 113 }) rooms; 114 }; 115 }; 116 117 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path 118 # that can wedge (endpoint stays half-active after switching outputs, and 119 # re-picking it fails with -16727 until AirPlayXPCHelper restarts). 120 services.shairport-sync = { 121 enable = true; 122 package = pkgs.shairport-sync-airplay2; 123 settings = { 124 general = { 125 name = "gg23"; 126 output_backend = "pipewire"; 127 # play early by about the hub -> room ROC hop, so AirPlay video 128 # stays in sync in the rooms 129 audio_backend_latency_offset_in_seconds = -0.04; 130 }; 131 pipewire.sink_target = "hub"; 132 }; 133 }; 134 users.users.shairport.extraGroups = [ "pipewire" ]; 135 136 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0 137 # stays closed). The unit comes from the package. 138 systemd.packages = [ pkgs.nqptp ]; 139 systemd.services.nqptp.wantedBy = [ "multi-user.target" ]; 140 systemd.services.shairport-sync = { 141 wants = [ "nqptp.service" ]; 142 after = [ "nqptp.service" ]; 143 }; 144 145 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed 146 services.avahi = { 147 allowInterfaces = [ "int0" ]; 148 openFirewall = false; 149 }; 150 151 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to 152 # pipewire. Restarted with pipewire, the retained command restores the room. 153 systemd.services.audio-room = { 154 wantedBy = [ 155 "multi-user.target" 156 "pipewire.service" 157 ]; 158 bindsTo = [ "pipewire.service" ]; 159 after = [ 160 "pipewire.service" 161 "mosquitto.service" 162 ]; 163 path = [ 164 config.services.pipewire.package 165 pkgs.mosquitto 166 ]; 167 script = '' 168 ${lib.concatMapStrings (d: '' 169 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 170 '') discovery} 171 route() { 172 for r in ${toString rooms}; do 173 pw-link -d hub "roc-$r" 2>/dev/null 174 case "$1" in "$r" | both) 175 i=0 176 until pw-link hub "roc-$r"; do 177 i=$((i + 1)) 178 [ "$i" -lt 10 ] || return 1 179 sleep 1 180 done 181 ;; 182 esac 183 done 184 } 185 mosquitto_sub ${mqtt} -t gg23/audio-room/set | while read -r room; do 186 case "$room" in 187 ${lib.concatStringsSep " | " choices}) ;; 188 *) echo "ignoring $room" >&2; continue ;; 189 esac 190 route "$room" && mosquitto_pub ${mqtt} -r -t gg23/audio-room/state -m "$room" 191 done 192 ''; 193 serviceConfig = { 194 DynamicUser = true; 195 SupplementaryGroups = [ "pipewire" ]; 196 Restart = "always"; 197 RestartSec = 2; 198 }; 199 }; 200 201 environment.systemPackages = [ 202 (pkgs.writers.writeDashBin "audio-room" '' 203 case "''${1-}" in 204 ${lib.concatStringsSep " | " choices}) 205 exec ${pkgs.mosquitto}/bin/mosquitto_pub ${mqtt} -r -t gg23/audio-room/set -m "$1" 206 ;; 207 *) 208 echo "usage: audio-room ${lib.concatStringsSep "|" choices}" >&2 209 exit 1 210 ;; 211 esac 212 '') 213 ]; 214}