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
5.9 kB 202 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 (shairport-sync). 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} or self.retiolum.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 services.shairport-sync = { 118 enable = true; 119 # AirPlay 1: the default package has no AirPlay 2 (no nqptp needed) 120 settings = { 121 general = { 122 name = "gg23"; 123 output_backend = "pipewire"; 124 # play early by about the hub -> room ROC hop, so AirPlay video 125 # stays in sync in the rooms 126 audio_backend_latency_offset_in_seconds = -0.04; 127 }; 128 pipewire.sink_target = "hub"; 129 }; 130 }; 131 users.users.shairport.extraGroups = [ "pipewire" ]; 132 133 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed 134 services.avahi = { 135 allowInterfaces = [ "int0" ]; 136 openFirewall = false; 137 }; 138 139 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to 140 # pipewire. Restarted with pipewire, the retained command restores the room. 141 systemd.services.audio-room = { 142 wantedBy = [ 143 "multi-user.target" 144 "pipewire.service" 145 ]; 146 bindsTo = [ "pipewire.service" ]; 147 after = [ 148 "pipewire.service" 149 "mosquitto.service" 150 ]; 151 path = [ 152 config.services.pipewire.package 153 pkgs.mosquitto 154 ]; 155 script = '' 156 ${lib.concatMapStrings (d: '' 157 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 158 '') discovery} 159 route() { 160 for r in ${toString rooms}; do 161 pw-link -d hub "roc-$r" 2>/dev/null 162 case "$1" in "$r" | both) 163 i=0 164 until pw-link hub "roc-$r"; do 165 i=$((i + 1)) 166 [ "$i" -lt 10 ] || return 1 167 sleep 1 168 done 169 ;; 170 esac 171 done 172 } 173 mosquitto_sub ${mqtt} -t gg23/audio-room/set | while read -r room; do 174 case "$room" in 175 ${lib.concatStringsSep " | " choices}) ;; 176 *) echo "ignoring $room" >&2; continue ;; 177 esac 178 route "$room" && mosquitto_pub ${mqtt} -r -t gg23/audio-room/state -m "$room" 179 done 180 ''; 181 serviceConfig = { 182 DynamicUser = true; 183 SupplementaryGroups = [ "pipewire" ]; 184 Restart = "always"; 185 RestartSec = 2; 186 }; 187 }; 188 189 environment.systemPackages = [ 190 (pkgs.writers.writeDashBin "audio-room" '' 191 case "''${1-}" in 192 ${lib.concatStringsSep " | " choices}) 193 exec ${pkgs.mosquitto}/bin/mosquitto_pub ${mqtt} -r -t gg23/audio-room/set -m "$1" 194 ;; 195 *) 196 echo "usage: audio-room ${lib.concatStringsSep "|" choices}" >&2 197 exit 1 198 ;; 199 esac 200 '') 201 ]; 202}