All checks were successful
Check / eval (push) Successful in 3m9s
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>
228 lines
9.8 KiB
Nix
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" ];
|
|
};
|
|
};
|
|
}
|