Files
Nomarchy/modules/home/dock-audio.nix
Bernardo Magri ce480f3669
All checks were successful
Check / eval (push) Successful in 3m9s
fix(docking): dock automatically; repair cards parked on pro-audio
Bernardo's retest on the AMD dev box. Two of the four reports were never
running the new code — both watchers are Hyprland `exec-once`, so a
`nomarchy-home` switch leaves the old processes alive until relogin; only
`nomarchy-dock-audio`, a user service, had picked the fix up. The journal
showed the old watcher's `event=monitoradded` next to the new audio tool's
`result=no-available-sink`, which is what gave it away. The other two were
real.

Automatic dock mode did not exist. The watcher auto-*undocked* when the last
external left, but on plug it only ever matched a display profile — and with
no profile saved and displayProfileAuto unset it did nothing at all, while
`Dock mode` was reachable only from the menu. Half the pair being automatic
is what made the dock feel broken: the session came home by itself but never
left. Mirror it, letting a matched profile win as the more specific layout.

The BenQ's audio was unreachable by any means because WirePlumber had the
GPU's DisplayPort card pinned to `pro-audio`, whose raw `pro-output-N` sinks
carry no ports, no routing and no volume — so no sink matched, nothing was
selectable, and the profile is stored per card and so survived forever. The
monitor's own USB card is input-only (every profile has sinks: 0), leaving
DisplayPort as the only path to its speakers. Repair the card when a plug
finds nothing routable, which is both the symptom and the only moment worth
touching profiles: narrow to cards carrying an *available* HDMI/DP port —
the monitor asking for audio over the cable — so the internal analog card is
never touched and a considered pro-audio setup elsewhere survives.

Verified V2: checks.docking-ux and checks.dock-audio pass with guards for
both; the repair filter picks exactly the GPU card → HiFi against the dev
box's live graph, leaving the BenQ USB, internal analog and Bluetooth cards
alone. Hotplug itself still needs hardware — HARDWARE-QUEUE round 6, which
now leads with the relogin requirement that invalidated round 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:56:52 +01:00

228 lines
9.8 KiB
Nix

# 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"));
# 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; }
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
"$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" ];
};
};
}