# Automatic audio-output follow for docks / external monitors (#100). # WirePlumber's stored default outranks priority.session, and some HDMI # codecs only expose their usable route after the display hotplug. The # Hyprland monitor watcher therefore calls `reprobe monitoradded` for the # unambiguous physical-plug event: restart the graph (the recovery used by # the old working flake), wait for it to settle, then explicitly select an # available dock-class sink. Ordinary sink changes never select anything, # so a manual speaker choice sticks until a fresh monitor plug. { config, lib, pkgs, ... }: let cfg = config.nomarchy; rules = import ../nixos/dock-audio-rules.nix; dockSinkRe = lib.concatStringsSep "|" (map (m: lib.removePrefix "~" m."node.name") (lib.concatMap (r: r.matches) rules."monitor.alsa.rules")); tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" '' set -u PACTL=${pkgs.pulseaudio}/bin/pactl JQ=${pkgs.jq}/bin/jq SYSTEMCTL=${pkgs.systemd}/bin/systemctl LOGGER=${pkgs.util-linux}/bin/logger rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" lock="$rt/nomarchy-dock-audio-reprobe.lock" TAB=$(printf '\t') log() { "$LOGGER" -t nomarchy-dock-audio -- "$*"; } wait_for_pulse() { tries=0 while [ "$tries" -lt 40 ]; do "$PACTL" info >/dev/null 2>&1 && return 0 tries=$((tries+1)); sleep 0.25 done return 1 } # Highest-priority dock-class sink whose active port is not explicitly # unavailable. With pro-audio profiles a sink may have no ports; its # existence is then the only availability signal PipeWire exposes. dock_sinks() { "$PACTL" --format=json list sinks 2>/dev/null \ | "$JQ" -r --arg re '^(${dockSinkRe})$' ' [ .[] as $sink | $sink | select(.name | test($re)) | select( ((.ports // []) | length) == 0 or .active_port == null or ([.ports[]? | select(.name == $sink.active_port) | (.availability // "unknown")][0] // "unknown") != "not available" ) ] | sort_by(.priority // 0) | reverse[] | "\(.name)\t\(.description // .name)"' } select_first_dock() { # $1 = observable trigger trigger="$1" candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate= if [ -z "$candidate" ]; then log "trigger=$trigger result=no-available-dock-sink" return 1 fi name=''${candidate%%"$TAB"*} desc=''${candidate#*"$TAB"} if "$PACTL" set-default-sink "$name" 2>/dev/null; then log "trigger=$trigger selected=$name description=$desc" ${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \ "Output → $desc" 2>/dev/null || true return 0 fi log "trigger=$trigger result=set-default-failed candidate=$name" return 1 } case "''${1:-watch}" in candidates) dock_sinks ;; select) wait_for_pulse || { log "trigger=''${2:-manual} result=pulse-unavailable"; exit 1; } select_first_dock "''${2:-manual}" ;; reprobe) trigger="''${2:-monitoradded}" # mkdir is the debounce/lock: simultaneous monitoradded events from # an MST dock collapse into one graph restart. Runtime-only state, # removed on every exit path. if ! ${pkgs.coreutils}/bin/mkdir "$lock" 2>/dev/null; then log "trigger=$trigger result=debounced" exit 0 fi trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM log "trigger=$trigger action=reprobe-start" sleep 2 "$SYSTEMCTL" --user restart \ pipewire.service pipewire-pulse.service wireplumber.service \ >/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error" if wait_for_pulse; then sleep 0.75 select_first_dock "$trigger" || true else log "trigger=$trigger result=pulse-did-not-return" fi ;; watch) # Selection is deliberately NOT done at service startup: a restart or # relogin while docked must not erase a manual speaker choice. Only # the compositor's fresh monitoradded event calls `reprobe`. wait_for_pulse || true # A graph restart closes `pactl subscribe`, sometimes with status 0. # Always resubscribe; log one concise closure line for diagnosis. while :; do if LC_ALL=C "$PACTL" subscribe 2>/dev/null \ | while IFS= read -r line; do case "$line" in *"'new' on sink"*|*"'change' on card"*) : ;; esac done then status=0; else status=$?; fi log "subscription-closed status=$status; retrying" sleep 1 wait_for_pulse || sleep 1 done ;; *) echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2 exit 64 ;; esac ''; in { config = lib.mkIf cfg.dockAudio.enable { home.packages = [ tool ]; systemd.user.services.nomarchy-dock-audio = { Unit = { Description = "Reprobe and select dock/monitor audio on display hotplug"; After = [ "graphical-session.target" ]; PartOf = [ "graphical-session.target" ]; }; Service = { ExecStart = "${tool}/bin/nomarchy-dock-audio watch"; Restart = "on-failure"; RestartSec = 2; }; Install.WantedBy = [ "graphical-session.target" ]; }; }; }