This repository has no description
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}