This repository has no description
0

Configure Feed

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

configs.gg23.audio: low-latency ROC/AirPlay hub on acheron with room selection

acheron gets a minimal system-wide pipewire with a virtual "hub" sink.
Any LAN machine sends into it via ROC while `gg23-audio` from
tools/gg23-audio runs (ROC sink loaded into pipewire-pulse, removed again
on Ctrl-C), barnacle via AirPlay 1 (shairport-sync, "gg23", pipewire
backend, mDNS on int0 only).
The hub monitor is linked to ROC sinks for shodan and icarus (room.nix),
chosen with `audio-room shodan|icarus|both|off` or from Home Assistant via
MQTT discovery (select + buttons). Retained MQTT commands restore the choice
after pipewire restarts.

The rooms have no stable LAN address, so the hub sends to their retiolum IPs
and they accept the ROC ports only from acheron on retiolum.

+304
+1
machines/acheron/config.nix
··· 10 10 ../../configs/gg23.nix 11 11 ../../configs/hass 12 12 ../../configs/snapserver.nix 13 + ../../configs/gg23/audio/hub.nix 13 14 ]; 14 15 15 16 services.earlyoom.enable = true;
+1
machines/icarus/config.nix
··· 10 10 ../../configs/pipewire.nix 11 11 ../../configs/network-manager.nix 12 12 ../../configs/snapclient.nix 13 + ../../configs/gg23/audio/room.nix 13 14 ../../configs/consul.nix 14 15 ../../configs/autoupdate.nix 15 16 ../../configs/sigexec/executor.nix
+1
machines/shodan/config.nix
··· 10 10 ../../configs/yellow-mounts/samba.nix 11 11 ../../configs/consul.nix 12 12 ../../configs/snapclient.nix 13 + ../../configs/gg23/audio/room.nix 13 14 ../../configs/sigexec/executor.nix 14 15 ../../configs/rad.nix 15 16 ../../configs/phonetpm-ssh.nix
+17
tools/gg23-audio/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { pkgs, ... }: 5 + { 6 + packages.gg23-audio = 7 + (pkgs.writeShellApplication { 8 + name = "gg23-audio"; 9 + runtimeInputs = [ 10 + pkgs.pulseaudio 11 + pkgs.gawk 12 + ]; 13 + text = builtins.readFile ./gg23-audio.sh; 14 + }).overrideAttrs 15 + { passthru.usage = builtins.readFile ./usage.kdl; }; 16 + }; 17 + }
+46
tools/gg23-audio/gg23-audio.sh
··· 1 + #!/usr/bin/env bash 2 + # Send audio to the gg23 audio hub on acheron (configs/gg23/audio/hub.nix) 3 + # via ROC while running: loads a ROC sink into pipewire-pulse and makes it the 4 + # default. Ctrl-C / SIGTERM unloads it (streams move back) and restores the 5 + # previous default sink. 6 + 7 + set -euo pipefail 8 + 9 + SINK=roc-gg23 10 + 11 + if [ "$#" -gt 0 ]; then 12 + echo "usage: gg23-audio (runs until Ctrl-C)" >&2 13 + exit 1 14 + fi 15 + 16 + if pactl list short modules | awk -v s="sink_name=$SINK" '$2 == "module-roc-sink" && index($0, s) { f = 1 } END { exit !f }'; then 17 + echo "gg23-audio: $SINK is already loaded (another gg23-audio running?)" >&2 18 + exit 1 19 + fi 20 + 21 + prev=$(pactl get-default-sink) 22 + 23 + id=$(pactl load-module module-roc-sink \ 24 + sink_name="$SINK" \ 25 + "sink_properties='node.description=\"gg23 (acheron hub)\"'" \ 26 + remote_ip=10.42.0.1 \ 27 + remote_source_port=10001 \ 28 + remote_repair_port=10002 \ 29 + remote_control_port=10003 \ 30 + fec_code=disable) 31 + 32 + cleanup() { 33 + if [ -n "${sleeper-}" ]; then kill "$sleeper" 2>/dev/null || true; fi 34 + pactl unload-module "$id" || true 35 + pactl set-default-sink "$prev" || true 36 + } 37 + trap cleanup EXIT 38 + trap 'exit 130' INT 39 + trap 'exit 143' TERM 40 + 41 + pactl set-default-sink "$SINK" 42 + echo "sending audio to gg23 hub, Ctrl-C to stop" >&2 43 + 44 + sleep infinity & 45 + sleeper=$! 46 + wait
+3
tools/gg23-audio/usage.kdl
··· 1 + bin "gg23-audio" 2 + about "Send audio to the gg23 audio hub on acheron via ROC until Ctrl-C" 3 + long_about "Loads ROC sink roc-gg23 into pipewire-pulse and makes it the default sink. Ctrl-C / SIGTERM unloads it and restores the previous default sink."
+202
configs/gg23/audio/hub.nix
··· 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 + }: 13 + let 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; 59 + in 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 + }
+33
configs/gg23/audio/room.nix
··· 1 + # Room speaker for the acheron audio hub (./hub.nix): plays what the hub 2 + # sends via ROC on the default output. 3 + { self, lib, ... }: 4 + let 5 + kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; }; 6 + acheron = (kartei.own.acheron or self.retiolum.own.acheron).ip4; 7 + in 8 + { 9 + services.pipewire.extraConfig.pipewire."10-audio-room"."context.modules" = [ 10 + { 11 + name = "libpipewire-module-roc-source"; 12 + args = { 13 + "local.ip" = "0.0.0.0"; 14 + "local.source.port" = 10001; 15 + "local.repair.port" = 10002; 16 + "local.control.port" = 10003; 17 + "fec.code" = "disable"; 18 + "sess.latency.msec" = 30; 19 + "source.name" = "roc-hub"; 20 + "source.props" = { 21 + "node.description" = "Audio hub (acheron)"; 22 + # keep the graph quantum small while the hub plays here 23 + "node.latency" = "256/48000"; 24 + }; 25 + }; 26 + } 27 + ]; 28 + 29 + # the hub sends over retiolum (see ./hub.nix), accept only acheron 30 + networking.firewall.extraInputRules = '' 31 + iifname "retiolum" ip saddr ${acheron} udp dport 10001-10003 accept 32 + ''; 33 + }