fix(audio): #138 — reprobe escalates to a graph restart, never opens with one
All checks were successful
Check / eval (push) Successful in 3m53s
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>
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
# 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.
|
||||
# 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
|
||||
@@ -65,6 +73,19 @@ let
|
||||
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=
|
||||
@@ -150,6 +171,19 @@ let
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user