# Automatic audio-output follow for docks / external monitors (#100). # WirePlumber's stored default outranks priority.session, so the Hyprland # monitor watcher calls `reprobe monitoradded` for the unambiguous physical- # plug event and we explicitly select an available dock-class sink. Ordinary # sink changes never select anything, so a manual speaker choice sticks until # a fresh monitor plug. # # `reprobe` escalates, cheapest first, and only as far as it must (#138): # select → repair a parked card's profile → restart the graph. The restart # is last because it is not free — it drops every client's connection to the # server, and clients that never reconnect (Chromium's audio service, hence # "Meet says I have no microphone") stay broken until the app itself is # restarted. It ran first here for one session-day because the old working # flake used it as a recovery; nothing had shown it was *needed*. Keep it as # the floor for the codec that only publishes its route after a re-probe, and # leave the common path — the sink is simply there to be chosen — untouched. { 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")); # Cards driven by ALSA UCM expose speakers and headphones as separate # sinks (HiFi__Headphones__sink), the headphone one existing only while # the jack is occupied — so on those machines a jack plug is a sink # appearing, not the route switch WirePlumber handles by itself. headphoneSinkRe = "alsa_output\\..*[Hh]eadphone.*"; 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 sink matching $1 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. sinks_matching() { "$PACTL" --format=json list sinks 2>/dev/null \ | "$JQ" -r --arg re "^($1)$" ' [ .[] 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)"' } dock_sinks() { sinks_matching '${dockSinkRe}'; } headphone_sinks() { sinks_matching '${headphoneSinkRe}'; } default_sink() { "$PACTL" get-default-sink 2>/dev/null; } # A dock sink appears some short time after the plug, not with it. Poll # before concluding there is nothing to select: "not yet" and "not ever" # look identical in one shot, and treating the first as the second is what # would drive an ordinary plug down to the graph restart. wait_for_dock_sink() { tries=0 while [ "$tries" -lt 20 ]; do [ -n "$(dock_sinks)" ] && return 0 tries=$((tries+1)); sleep 0.25 done return 1 } select_first() { # $1 = sink lister, $2 = observable trigger lister="$1"; trigger="$2" candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate= if [ -z "$candidate" ]; then log "trigger=$trigger result=no-available-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 } # A card parked on `pro-audio` (or `off`) publishes no sink we can route: # pro-audio exposes raw `pro-output-N` nodes with no ports, no routing and # no volume, so a monitor's audio is unreachable however it is chosen — # and WirePlumber stores that profile per card, so it survives forever # once set. When a dock plug finds nothing to select, repair the cards # that carry an available HDMI/DisplayPort output by moving them to their # best real profile. Deliberately narrow: an internal analog card is # never touched, and a card already on a routable profile is left alone, # so a considered pro-audio setup on anything else survives. repair_dock_cards() { # $1 = observable trigger trigger="$1" cards=$("$PACTL" --format=json list cards 2>/dev/null \ | "$JQ" -r ' .[] | select(.active_profile == "pro-audio" or .active_profile == "off") # An available HDMI/DP port is the monitor itself asking for # audio over the cable — that is what makes this card a dock. | select([.ports[]? | select((.type // "") == "HDMI") | select((.availability // "unknown") == "available")] | length > 0) | . as $card | [ $card.profiles // {} | to_entries[] | select(.key != "pro-audio" and .key != "off") | select((.value.sinks // 0) > 0) | select((.value.available // true) != false) ] | sort_by(-(.value.priority // 0)) | if length == 0 then empty else "\($card.name)\t\(.[0].key)" end') || cards= [ -n "$cards" ] || return 1 printf '%s\n' "$cards" | while IFS= read -r row; do card=''${row%%"$TAB"*} prof=''${row#*"$TAB"} if "$PACTL" set-card-profile "$card" "$prof" 2>/dev/null; then log "trigger=$trigger repaired-card=$card profile=$prof" else log "trigger=$trigger repair-failed=$card profile=$prof" fi done } select_first_dock() { # $1 = observable trigger select_first dock_sinks "$1" && return 0 # Nothing routable to select is the symptom a parked card produces, so # it is also the only moment worth touching profiles. Retry once the # repaired card has had time to publish its sinks. repair_dock_cards "$1" || return 1 sleep 1 select_first dock_sinks "$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 wait_for_pulse || { log "trigger=$trigger result=pulse-unavailable"; exit 1; } # Rung 1 + 2: the sink is there to be chosen, or a parked card needs # its profile repaired first (select_first_dock does both). This is # the whole job on a healthy plug — and it keeps every audio client's # connection alive, which is the point. wait_for_dock_sink || log "trigger=$trigger result=no-dock-sink-yet" select_first_dock "$trigger" && exit 0 # Rung 3: nothing routable even after the card repair. Restart the # graph — the inherited recovery, now confined to the case that has # actually run out of cheaper options — and try once more. log "trigger=$trigger action=graph-restart-fallback" "$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 ;; headphones) # Follow the jack, but only ever *towards* the headphones. On unplug # the sink disappears and any pin naming it goes stale, which makes # WirePlumber fall back by priority on its own — to the dock when # docked, to the speakers otherwise — so there is nothing to undo. wait_for_pulse || exit 0 hp=$(headphone_sinks | ${pkgs.coreutils}/bin/head -n 1) || hp= [ -n "$hp" ] || exit 0 name=''${hp%%"$TAB"*} [ "$(default_sink)" = "$name" ] && exit 0 select_first headphone_sinks "''${2:-jack}" || true ;; watch) # Dock 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`. # # The jack is different, and is why this loop exists. A sink that # WirePlumber has been told to prefer (by us on a dock plug, or by # the user in the Audio menu / a mixer) is stored as the configured # default, and that outranks every priority rule — so on UCM cards, # where the headphones are a sink of their own rather than a route, # plugging them in could no longer steal the audio back. Watch for # that sink appearing and select it explicitly. 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"*) # Re-entering the tool keeps the decision out of this # read loop, which must never block on the graph. "$0" headphones jack || true ;; 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]|headphones [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" ]; }; }; }