All checks were successful
Check / eval (push) Successful in 3m53s
Bernardo's Meet calls lost their microphone because our own dock-audio reprobe restarted PipeWire/Pulse/WirePlumber on every monitoradded (#100). Chromium's audio service does not reconnect when the server disappears under it, so it kept running and enumerated nothing; Zoom, started fresh into a settled graph, looked fine and made it read like a browser bug. reprobe now escalates cheapest-first — select → repair a parked card's profile → restart the graph — so an ordinary plug never touches the graph and no client loses its connection. wait_for_dock_sink polls for the sink the plug is about to publish, so "not yet" is not mistaken for "not ever" and cannot fall through to the restart. The restart stays as rung 3 for the codec that only publishes its route after a re-probe: the one claim the original comment made, and the only one it never demonstrated. Verified on the dev box against the live graph: rung 1 selects the same sink the restart-first path picked (HiFi__HDMI1__sink) in 2.0s instead of ~3.5s with pipewire/pulse/wireplumber MainPIDs unchanged; rung 3 reachable and restarts+retries. Diagnosis proof was ordering across four boots — restart-after-Chromium in both broken sessions, 24s before it in the healthy one — and Bernardo reproduced on demand with a dock/undock. Sweep: BACKLOG #138 deleted, ROADMAP gets the design record (incl. the stale restart-first sentence in the #100 entry), V3 queued on the dev box (relogin first — the watcher is exec-once), journal entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
12 KiB
Nix
262 lines
12 KiB
Nix
# 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" ];
|
|
};
|
|
};
|
|
}
|