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